DEV Community

Cover image for What Happens When 20,000 People Click the Same Seat
Mohamed Abdelbary
Mohamed Abdelbary

Posted on

What Happens When 20,000 People Click the Same Seat

Building a seat picker is easy. You render a map, you colour the free seats blue, you let people click.

Then tickets go on sale for a big fixture, and thousands of people click the same seat inside the same second.

I spent several years building booking systems for large events — one of them handling twelve to twenty thousand spectators per match. Almost none of the difficulty was in the seat map. All of it was in that second.

I've put a small NestJS reference implementation on GitHub — aboalynx/seatmap — with a test suite that fires 200 simultaneous requests at a single seat and asserts that exactly one wins. This post is about why it's built the way it is.

Three ways this goes wrong

Stale availability. The map renders and is already wrong. A user picks a seat that sold while the page was loading. Annoying, not fatal.

The lost update. Two users pass an "is this seat free?" check milliseconds apart. Both proceed. Both are told the seat is theirs. One of them finds out at the turnstile. This one is fatal.

The half-granted basket. A family asks for four seats together. Two are granted, two are grabbed mid-request by someone else. Now nobody has a usable basket and two seats are stranded until something cleans them up.

The instinct is to reach for a transaction:

SELECT * FROM seats WHERE id = 42 AND sold = false;
-- ... application logic ...
UPDATE seats SET sold = true WHERE id = 42;
Enter fullscreen mode Exit fullscreen mode

This solves none of them. The gap between the read and the write is exactly where every one of these bugs lives, and no amount of application-level checking closes it.

Selection is not purchase

The design decision everything else follows from: clicking a seat does not sell it.

Clicking creates a hold — a short-lived, expiring claim. Only checkout writes a row to the database.

browser ──click──▶  POST /holds   ──▶  Redis  (check-then-set, in Lua)
                                          │  hold:{eventId}:{seatId} = token
                                          │  expires on its own
                                          ▼
browser ──pay───▶  POST /orders   ──▶  Redis verify
                                          ▼
                                       Postgres INSERT
                                       UNIQUE (event_id, seat_id)  ◀── the actual guarantee
Enter fullscreen mode Exit fullscreen mode

Two stores, two very different jobs.

Why holds live in Redis

Holds are high-churn, short-lived, and mostly abandoned. People close tabs. They lose signal on stadium wifi. They wander off halfway through payment. In a busy sale, the overwhelming majority of holds never become orders.

Model that in Postgres and you get one of two bad outcomes: a held_until column that every availability query has to filter on, or a reaper job deleting expired rows on a schedule. Either way you're doing write amplification on your hottest table, for data that's worthless within two minutes.

Redis keys expire by themselves. No reaper, no cleanup cron, no dead rows. The worst case for an abandoned seat is that it's locked for the TTL and then quietly returns to sale.

Why Postgres is still the source of truth

Here's the part I think matters most, and the part I've seen skipped.

Redis is an optimisation. It is not the guarantee. You could flush the entire Redis instance in the middle of a sale and still not double-sell a seat, because of one line in the schema:

CREATE TABLE order_seats (
  order_id       BIGINT NOT NULL REFERENCES orders(id),
  event_id       BIGINT NOT NULL REFERENCES events(id),
  seat_id        BIGINT NOT NULL REFERENCES seats(id),
  ticket_type_id BIGINT NOT NULL REFERENCES ticket_types(id),
  price_cents    INT NOT NULL,
  UNIQUE (event_id, seat_id)   -- this is the whole guarantee
);
Enter fullscreen mode Exit fullscreen mode

Everything else in the repository is a performance and UX layer sitting on top of that constraint. When the two stores disagree, the database wins.

Which means the checkout path has two gates, and they do different jobs:

// Gate 1: cheap, rejects the common failure (expired hold) without
// touching the database.
const stillHeld = await this.holds.verify(eventId, seatIds, holdToken);
if (!stillHeld) throw new ConflictException('Hold expired');

// Gate 2: the one that actually decides.
try {
  return await this.db.transaction(async (client) => { /* insert order rows */ });
} catch (err) {
  if (err?.code === '23505') {
    throw new ConflictException('Seats sold while you were checking out');
  }
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Catching 23505 and translating it to a 409 is small but worth stating plainly: losing a race is a business outcome, not a crash. If your users can trigger a 500 by clicking at the same time as someone else, you don't have a concurrency bug so much as an error-handling one.

Why the hold is a Lua script

The all-or-nothing basket rule can't be done with N round-trips. Between checking seat 3 and writing seat 4, another client interleaves — and you're back to the half-granted basket.

So the check and the set happen inside a single Redis execution:

for i = 1, #KEYS do
  if redis.call('EXISTS', KEYS[i]) == 1 then
    return {0, KEYS[i]}          -- nothing acquired; report the conflict
  end
end
for i = 1, #KEYS do
  redis.call('SET', KEYS[i], ARGV[1], 'PX', ARGV[2])
end
return {1, ''}
Enter fullscreen mode Exit fullscreen mode

Nothing can interleave, so a basket is granted whole or not at all.

Notice it returns which key conflicted. That's not decoration — it lets the UI say "Row C Seat 12 just went" instead of "something failed", and the difference between those two messages is most of the perceived quality of a booking flow.

Why release is compare-and-delete

Releasing a hold checks the token first. This looks like paranoia until you walk the sequence:

  1. User A's hold on seat 12 expires.
  2. User B acquires seat 12.
  3. User A's browser fires its cleanup request.

A bare DEL at step 3 deletes B's live hold and hands their seat to a third user. B finds out at checkout, or worse, at the gate.

local released = 0
for i = 1, #KEYS do
  if redis.call('GET', KEYS[i]) == ARGV[1] then
    redis.call('DEL', KEYS[i])
    released = released + 1
  end
end
return released
Enter fullscreen mode Exit fullscreen mode

Now step 3 is a no-op. This is the same reasoning behind the token check in a distributed lock release, and it's the bug I'd look for first in anyone else's implementation.

About trusting the browser

The client sends seat IDs lifted straight out of the DOM. That's fine, and I want to be explicit about why, because "never trust the client" often gets applied as a reflex rather than a design.

Those IDs are lookup keys, not authorisation. Edit the SVG in devtools and you get nowhere:

  • A seat that isn't on sale has no allocation row, so pricing fails.
  • A sold seat trips the unique index.
  • A held seat fails the Redis acquire.

The server never needs to trust the map it rendered, because every path through it re-derives the truth from storage.

Ticket types, and why availability isn't a column on the seat

Seats belong to the venue. Availability belongs to the event. A seat is on sale for a given event only if a row exists in seat_allocations — which also carries its ticket type, and therefore its price.

That indirection buys more than it costs. You can hold a block back for sponsors by simply not inserting allocation rows. You can price the front rows differently. You can release held-back seats an hour before kickoff. None of it touches the seat map or the seats table.

This is the part that tends to separate a demo from something that survived contact with an actual operations team, and it's the requirement I'd never have guessed before doing it for real.

Testing the thing that actually breaks

A test that books one seat successfully proves nothing. The property worth asserting is about the distribution of responses when many requests arrive together:

const responses = await Promise.all(
  Array.from({ length: 200 }, () =>
    request(baseUrl).post(`/events/${eventId}/holds`).send({ seatIds: [seatId] }),
  ),
);

expect(responses.filter((r) => r.status === 201)).toHaveLength(1);
expect(responses.filter((r) => r.status === 409)).toHaveLength(199);
Enter fullscreen mode Exit fullscreen mode

One winner, 199 conflicts, no 500s. The suite covers five properties:

Test Property
200 concurrent holds on one seat exactly one 201, 199 × 409
Overlapping baskets, 60 concurrent one winner; the loser holds nothing
50 concurrent checkouts, same token one order row; no 500s
Release with the wrong token hold survives
Expired hold seat resells; the stale token is refused

Promise.all, not a loop with await in it. Awaiting in a loop serialises the requests and tests nothing.

One practical note that cost me a while: at 200 concurrent requests, letting supertest boot a fresh server per request exhausts the connection backlog and you spend an afternoon debugging ECONNRESET instead of seats. Bind the app to a real ephemeral port once and hit that.

What this doesn't do

Being honest about the edges, because the edges are where the next problem is:

  • Availability is polled, and computed by pipelining EXISTS per seat. Fine for a few hundred seats, wrong for a full venue. There you'd maintain a per-event hash of held seats alongside the TTL keys, or consume keyspace expiry events, and push deltas over SSE or WebSockets.
  • One Redis node. The hold's atomicity is single-node atomicity. Under Redis Cluster the basket keys must hash to one slot — a hash tag like hold:{event:42}:seat:9 — and a failover can lose a hold. That's survivable exactly because holds aren't the source of truth. If they were, this would be a much longer post about Redlock.
  • No payment step. Real checkout means the hold has to outlive a redirect to a payment provider, which is what extend() is for.
  • No queue. Above a certain burst, the right answer isn't a faster hold — it's a virtual waiting room in front of the map.

The short version

Put the expiring, high-churn state in the store that's built for expiring, high-churn state. Put the guarantee in a database constraint, where it can't be argued with. Make the boundary between them explicit, and test the race rather than the happy path.

Code is at github.com/aboalynx/seatmap — MIT, synthetic data, npm test runs the races for real.

If you've built one of these, I'd like to hear how you handled the payment-redirect window. It's the piece I'm least satisfied with.

Top comments (0)