← Back to writingArchitecture / 2026.11

No Database, Just Redis: The Architecture Behind an Anonymous Booking Flow

I went in expecting to find the usual shape of a system that handles money: a queue somewhere, a worker process, maybe a cron job reconciling something overnight. None of that exists in this codebase. What replaces all of it is a single Redis instance and a naming convention, and once I understood why, it stopped looking like a gap and started looking like a decision.

the flow is the session

A user can search for a flight, see results, pick seats, add bags, and pay, without ever creating an account or logging in. That's not a UX nicety bolted on top of an authenticated system. It's the actual architecture. The moment a search starts, the server mints a flowId and writes a Redis entry before the upstream search call has even returned, so the frontend can hydrate the search bar while the real search is still in flight:

flow:{flowId}                            (main flow snapshot, 30 min TTL)
flow:{flowId}:search-context             (24h TTL, outlives the flow itself)
flow:{flowId}:price
flow:{flowId}:seatmap / :addons / :baggage
flow:{flowId}:payment-methods
flow:{flowId}:checkout-session:{brand}   (one embedded checkout session per gateway brand)
flow:{flowId}:payment-status / :book-result

Every step of the booking flow reads and writes a corner of that same key. There's no row in a database that represents "a booking in progress," because there doesn't need to be one. The flow only needs to survive for as long as someone is actually mid booking, and Redis's TTL is exactly that lifecycle already built in, so nothing has to clean it up later.

The part that actually reframed this for me is a line in the routing security doc: for routes tagged flow-scoped, a valid flowId is the only thing checked, no user identity required, and a JWT is accepted if present but never demanded. The Redis key itself is the credential for that flow. Anyone who knows the flowId can act on that booking, and that's fine, because knowing the flowId means the browser that started the search still has it in memory or in the URL, the same trust model a session cookie would give you, just scoped to one booking instead of one account.

what happens once there's actually a person behind it

Logging in adds a second, layered system on top of the anonymous one, it doesn't replace it. The account backend issues its own raw token, and the BFF wraps that in a JWT pair it mints itself, a short lived access token plus an optional longer one, only issued at all if the user ticked remember me. Skip that box and the access token gets set as a session cookie with no expiry of its own, so it dies with the browser tab instead of sitting around on disk.

A background client side interval proactively refreshes that access token while a tab is open and visible, pausing the moment the tab goes into the background, so an authenticated tab left open for an hour doesn't silently expire out from under someone reading a confirmation page.

The one gap here worth stating plainly, because the project's own internal docs state it plainly: the longer lived refresh token has no server side revocation. There's no denylist, no session record in Redis for it, nothing. Refreshing a token is a stateless signature check, full stop. Somebody made the honest call to write that down as a known limitation rather than pretend it isn't there, which is the same instinct I keep running into across this codebase's own documentation, and I'd rather build on top of a system that names its gaps than one that hopes nobody asks.

banks don't get cookies

Tenant A, the bank partnership already covered in the piece about the multi tenant rewrite itself, runs its whole flow inside a native WebView, and WebViews from a banking app routinely block cookies outright. This codebase handles that with a small static map: each of those tenants gets its own header name instead, Tenant A's session travels as a custom header, a second bank partner uses a different header again, and the session middleware checks for cookies first, then falls back to whichever header that specific tenant is configured to use.

That's a genuinely different code path from the anonymous flowId session and the logged in JWT pair, a third kind of session, identified by tenant rather than by account or by flow, existing purely because a WebView's security model won't allow the other two. The route tiers doc gives it its own name, tenant-session, and there's a fourth tier again for a partner handshake that arrives as an encrypted token and skips the app's own JWT check entirely, verified against the partner's key instead. Four different ways to prove you're allowed to be here, chosen per route, not applied uniformly, because none of the four could actually cover all of the others.

no queues, just a clock

Once I understood the Redis design, the missing queue stopped being a surprise. A payment gateway's embedded checkout authorizes on its own timeline, independent of the booking backend, so the client polls a status endpoint every few seconds until a finalize call can confirm the booking actually went through. That's the entire async story. No webhook consumer, no job that picks the result up later, a browser tab asking politely every three seconds until the answer changes.

The booking countdown a user sees on screen is the same idea from the other direction, a plain client side timer mirroring the same 30 minute TTL that's already expiring the Redis key server side, so the UI and the actual expiry never have a reason to disagree. Nothing server side is counting down. The countdown is just narrating a TTL that Redis was already going to enforce regardless of whether anyone was watching a clock on screen.

Once I stopped looking for a queue and started reading it as a stateless request response system with Redis as the only place state gets to persist, the absence made sense. A queue exists to let a system do work later, safely, after the request that triggered it has already returned. Nothing in this flow needs work done later. It needs a fact remembered for thirty minutes and forgotten cleanly after, and a TTL does that without anyone having to build or operate a second system to get it.

small things that tell you how a system actually gets run

A raw booking API call to add a payment card started failing intermittently with a spurious server error, and the fix ended up living in an unusual place: a hand rolled HTTPS client using Node's raw sockets instead of the normal fetch call, specifically because fetch splits a request's headers and body into two separate writes on the wire, and something between this app and the upstream's SOAP encryption endpoint couldn't reassemble a split write correctly. Sending the whole request as one write fixed it. It's an ugly workaround for somebody else's bug, and the comment above it says so honestly instead of pretending it's a stylistic choice.

The error logging plugin has its own small, deliberate touch: on every uncaught error, it logs which of the three session cookies were present at the time, not their values, just presence or absence, because a real incident once needed exactly that signal to diagnose a class of login failure that only showed up for returning users right after a deploy. It also dedupes identical errors within a two second window so one cascading failure doesn't flood the logs with the same stack trace a hundred times. Neither detail is architecturally significant on its own. Both exist because a specific, real problem needed exactly that piece of information and nothing more general was lying around to answer it.

the actual lesson

The interesting decision here was never about infrastructure. It was about deciding, route by route, exactly how much identity a given action actually needs, and then refusing to default to the most, requiring an account for booking a flight, when the honest answer was often none at all. Redis ended up doing the job a database, a session store, and a job scheduler would normally split between them, not because it's a clever piece of technology but because everything this system actually needed to remember had a natural expiry built into the problem itself. Once that was true, a TTL was the whole solution, and building a queue or a session table on top of it would have been solving a problem this system doesn't have.

Tech used in this article

  • Node.js
Continue readingRedis holds the checkout session. It has no opinion on whether the gateway is telling the truth about it.Hardening a Payment Checkout: Trust Boundaries, Races, and a Gateway That Lied