← Naman GuptaAll writing

Backend · Concurrency · Published

Zero oversold seats under 1,000 concurrent bookings

FrontRow · 2026~9 min read

I fired 1,000 concurrent requests at the last seat of a show. Exactly one booking succeeded; the other 999 were cleanly rejected. Run it at 500 buyers against 5 seats and you get exactly 5 sold. The number that matters in both runs is the same: 0 oversold, start to finish in single-digit milliseconds.

That guarantee is the whole point of FrontRow. The design behind it is two independent layers, and the part doing the real work is a single SQL line you could miss if you blinked.

The problem (and why it's actually hard)

A popular show puts thousands of people on the same few seats at the same instant. The requirement is one sentence — never sell the same seat twice — and it is trivial to state and genuinely hard to guarantee.

The naive version is a read-then-write: check whether the seat is free, then mark it taken. Under concurrency that's a race. Two requests both read "available" before either writes, and both write "booked." You've oversold. The window is microseconds wide, which is exactly why it never shows up in a demo and always shows up in production.

There's a second trap on the other side. The obvious fix is to lock the row — but a booking has a slow human in the middle who takes thirty seconds to enter a card. Hold a database row lock across that payment window and you've serialized your hottest rows behind your slowest actors. You don't oversell; you just stop scaling.

So the real requirement is narrower: prevent the double-book without holding a database lock across the payment window. And a booking has an async tail — confirmation, ticketing, analytics — that must not be lost if a node dies mid-write. That last part rules out the easy "just publish to Kafka after you commit" shortcut, for reasons I'll get to.

How it works

The booking splits into two phases with two different stores, each doing the one thing it's good at.

A Redis hold (SET NX PX) resolves the race to grab a seat: it's atomic, O(1), and the PX gives it a TTL so an abandoned hold frees itself with no cleanup job. Critically, the hold lives in Redis, not Postgres — so the slow "user is paying" window never touches the database. That's the fast gate and the good UX.

The Postgres conditional write is the authoritative guarantee. When the user confirms, a single transactional UPDATE flips the seat from available to booked, but only if it's still available. Everything else — the read model, the live feed, revenue stats — is downstream of a BookingConfirmed event, fed to Kafka through a transactional outbox.

browser │ browse / watch seat map (WebSocket /ws/frontrow) ▼ ┌──────────────────────┐ │ Redis hold │ SET NX PX │ (fast gate, TTL) │ atomic · auto-frees abandoned holds └──────────┬───────────┘ keeps the "paying" window out of the DB │ user confirms (idempotency key) ▼ ┌──────────────────────┐ │ Postgres │ UPDATE … WHERE booking_id IS NULL │ (the authority) │ succeeds at most once per seat └──────────┬───────────┘ + writes an outbox row in the SAME tx │ ▼ ┌──────────────────────┐ │ outbox relay │ SELECT … FOR UPDATE SKIP LOCKED │ (@Scheduled poll) │ → publish to Kafka (DLQ for poison msgs) └──────────┬───────────┘ ▼ Kafka → @KafkaListener projections (% sold · revenue · live "just booked" feed)
Browse → Redis hold (fast gate) → confirm → Postgres conditional write (authoritative) → outbox → Kafka → read-model projections

The design decisions that mattered

Two independent layers, because Redis can't be the source of truth

The Redis hold is an optimization. Postgres is the truth. That split is deliberate, not redundant.

Redis is fast and atomic, but it can lose a key on failover — a replica promoted mid-flight may not have the latest SET. If Redis were authoritative, that lost key is a double-booked seat. So Redis only does the cheap, recoverable job of keeping most contenders out of the database, and the irrecoverable decision — this seat is now sold — lives in Postgres, which I trust to be durable and transactional.

To prove the layers are actually independent, the demo has a toggle that disables the Redis layer entirely. With holds off, Postgres alone still prevents overbooking — you just see more rejected transactions, because more contenders now collide at the database instead of being filtered upstream. Same guarantee, more contention reaching the authority.

Optimistic over pessimistic — and the null-guard is the optimistic check

Here is the line doing the real work:

sql
UPDATE seat SET booking_id = :bid
WHERE show_id = :sid AND seat_id IN (:seats) AND booking_id IS NULL

The whole guarantee is in AND booking_id IS NULL. The database evaluates that predicate atomically as part of the write, so for any given seat the UPDATE matches at most once — the first transaction to commit claims it; every later one matches zero rows and is rejected. There is no read-then-write window for a race to slip through, because the check and the write are the same statement.

It books a whole seat set all-or-nothing: if the number of rows updated doesn't equal the number of seats requested, the transaction rolls back, so I never sell someone a subset of the seats they asked for.

I chose optimistic concurrency here, and I rejected the alternatives on purpose:

The honest trade-off: optimistic wins when contention is spread across many seats — which is the common case, since most bookings don't actually collide. Under extreme contention on a single row, optimistic retries thrash, and a queue or a pessimistic lock would beat it. I implemented optimistic and documented where it loses rather than pretending it's universal.

A transactional outbox instead of dual-writing to Kafka

After a booking commits, the async tail has to fire: ticketing, the read model, the live feed. The tempting shortcut is to publish to Kafka right after the Postgres commit. That's a dual write, and it isn't atomic — the process can die in the gap between "committed to Postgres" and "published to Kafka," and now the booking exists but nothing downstream knows. The seat is sold and no ticket is issued.

The fix is to make the event part of the same transaction as the booking. Under the real stack, confirm writes the booking and an outbox row in one Postgres transaction. A separate relay polls unpublished rows with SELECT … FOR UPDATE SKIP LOCKED (so concurrent relay runs don't fight over the same rows) and publishes them to Kafka; a @KafkaListener projects them into the read model, with a dead-letter topic catching poison messages. Either both the booking and its event commit, or neither does.

This is at-least-once, not exactly-once — Kafka and the outbox can redeliver — so the consumer dedupes by booking id rather than pretending exactly-once exists. Effectively-once is the honest description.

In-memory by default, real infra profile-gated — and the deployed demo runs in-memory

The default Spring profile runs the whole thing in memory: no Postgres, no Redis, no Kafka. The in-memory seat store claims each seat with a per-seat compare-and-set (null → bookingId), which is the in-process analogue of the SQL null-guard — lock-free, and it lets bookings of different seats proceed in parallel so the concurrency test actually exercises contention instead of serializing behind one lock.

The point of the in-memory default is that the entire demo — including the stampede — runs on a single free instance with zero external services. The real persistence / redis / kafka profiles swap in the genuine stores and compose in any subset; when Kafka is off, the outbox drains into the in-process read model instead of a broker.

To be straight about it: unless I provision the real Neon / Upstash / Redpanda backends and flip one env var, the live demo is running the in-memory path. The Postgres JdbcSeatStore, the transactional outbox committer, and the SKIP-LOCKED relay are all built and test-covered, but the end-to-end three-service run is the one thing only a real deploy (or CI with Docker) exercises. I'd rather say that than imply a production cluster is humming behind a portfolio link.

Does it actually work?

Two kinds of evidence, and they prove different things.

Live, in the browser. The "stampede" button fires N concurrent buyers — each a Java 21 virtual thread, all released together — at K seats and measures the result:

both start to finish in single-digit milliseconds. Or, more viscerally, open the page in two tabs and fight over one seat — exactly one tab wins.

Tests, Docker-free, 53/53 green. A 300-buyer multi-threaded concurrency proof (N threads against the seat store, assert exactly one booking per seat), hold all-or-nothing and TTL expiry, idempotent confirm and can't-snipe-someone-else's-hold, the stampede assertion (500 → exactly 5), and a real Kafka broker via @EmbeddedKafka — publish → topic → consumer → projection, plus a poison message routed to the dead-letter topic. The real Postgres/Redis paths run under Testcontainers in CI.

What this proves: the correctness invariant holds under genuine in-process concurrency, and the Kafka path works against a real broker. What it doesn't prove: throughput against a real networked Postgres under sustained production load, or the failover behavior of the combined three-service stack. The stampede shows the seat math is right; it isn't a load test of a real database.

What I'd do differently, and what's next

The outbox relay is a single consumer. It's a @Scheduled poll on one instance, which is fine for this scale but a bottleneck at real volume. At scale I'd shard the relay by partition key, or replace the poll-the-outbox pattern with Debezium change-data-capture reading the Postgres WAL directly — same dual-write-safety, no polling lag.

The hot-seat case is the known weakness. Optimistic concurrency is the right call when contention is spread, but a single wildly popular seat (front row, center) means everyone collides on one row and retries thrash. The fix is to stop pretending it's the common case: put a queue in front of the hottest rows so contenders are serialized cheaply instead of retrying against the database. That's deliberately out of scope here — I documented the trade-off rather than over-engineering for a load profile the demo doesn't have.

And the honest one already stated: the deployed demo runs in-memory unless the real backends are provisioned. The real-infra code paths rest on unit, EmbeddedKafka, and Testcontainers tests plus review — not on a live three-service run.

Try it

The live demo (and the stampede button) is at /frontrow; the code is on my GitHub. Open it in two tabs and try to oversell a seat.