Skip to content

Repository files navigation

Concurrency-safe seat selection

A small NestJS service that answers one question properly: what happens when thousands of people click the same seat at the same moment?

Building a seat picker that works when you click it is easy. Building one that still works when a popular fixture goes on sale — when hundreds of requests land on the same seat inside the same millisecond — is a different problem, and it is the only part of ticketing that is genuinely hard.

  • Redis holds seats optimistically, with a TTL.
  • PostgreSQL decides what has actually been sold.
  • A test suite fires 200 simultaneous requests at one seat and asserts that exactly one wins.

Seat map with a live selection

Blue is Standard, purple Premium, yellow the current selection. Grey seats are held by someone else or already sold — state is composed at read time from PostgreSQL and Redis.

npm install
docker compose up -d
npm run migrate && npm run seed
npm run start:dev     # http://localhost:3000
npm test              # the part worth reading

The problem

Three failure modes, in increasing order of embarrassment:

  1. Stale availability. The map renders, and is wrong immediately. Someone picks a seat that was sold while the page was loading.
  2. The lost update. Two users pass an "is this seat free?" check milliseconds apart, both proceed, both get told the seat is theirs. One of them finds out at the turnstile.
  3. The half-granted basket. A user asks for four seats together. Two are granted, two are taken by someone else mid-request. Now nobody has a usable basket and two seats are stranded.

A SELECT followed by an INSERT solves none of these. The gap between the read and the write is where every one of these bugs lives.

The design

Selection is not purchase. Clicking a seat creates a hold: a short-lived, expiring claim. Only checkout writes a row to the database. This distinction is the whole design.

  browser ──click──▶  POST /holds     ──▶  Redis  (SET NX + PX, via Lua)
                                             │
                                             │  hold:{eventId}:{seatId} = token
                                             │  expires on its own
                                             ▼
  browser ──pay───▶  POST /orders     ──▶  Redis verify (does this token still own it?)
                                             │
                                             ▼
                                          Postgres INSERT
                                          UNIQUE (event_id, seat_id)  ◀── the actual guarantee

Why Redis for holds

Holds are high-churn, short-lived, and mostly abandoned. Users close tabs, lose signal, and wander off mid-payment constantly. Modelling that in Postgres means either a held_until column that every availability query has to filter on, or a reaper job deleting expired rows — write amplification on your hottest table, for data that is worthless within two minutes.

Redis keys expire by themselves. No reaper, no cleanup cron, no dead rows.

Why Postgres is still the source of truth

Redis is an optimisation, not a guarantee. Flush the entire Redis instance mid-sale and no seat can be double-sold, because of one line in db/schema.sql:

UNIQUE (event_id, seat_id)

Everything else in this repository is a performance and UX layer on top of that constraint. When the two stores disagree, the database wins. A test asserts this explicitly: it fires 50 concurrent checkouts using the same valid token and confirms exactly one order row exists afterwards.

Why the hold is a Lua script

The all-or-nothing basket rule can't be done with N round-trips. Between your check on seat 3 and your write to seat 4, another client interleaves — and you get the half-granted basket from failure mode 3. The acquire script does check-then-set for every seat inside a single Redis execution, so nothing can interleave.

It also returns which seat conflicted, so the UI can say "Row C Seat 12 just went" instead of "something failed".

Why release is compare-and-delete

Releasing a hold checks the token first, and this matters more than it looks:

  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 would delete B's live hold and quietly hand their seat to a third user. The token check makes step 3 a no-op. There is a test for this.

Trusting the client

The browser sends seat IDs lifted straight out of the DOM, and that is fine — they are lookup keys, not authorisation. Edit the SVG in devtools and you get nowhere: an unallocated seat has no allocation row, a sold seat trips the unique index, and a held seat fails the acquire. The server never needs to trust the map it rendered.

Ticket types and distribution

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 a lot: hold back a block for sponsors by not inserting allocation rows, price the front two rows as Premium, or re-release held-back seats an hour before kickoff — all without touching the seat map itself. The seeded venue keeps row H off sale entirely to show the case.

Tests

npm test
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

These assert on the distribution of responses, not on individual requests. A single request succeeding proves nothing about a race.

Scaling notes

Honest about what this demo does not do:

  • Availability is polled, and computed by pipelining EXISTS per seat. Fine for a few hundred seats. For a full venue, keep a per-event hash of held seats updated alongside the TTL keys (or consume keyspace expiry events), and push deltas over SSE or WebSockets instead of polling.
  • One Redis node. The hold's correctness depends on single-node atomicity. Under Redis Cluster the basket keys must hash to one slot — use a hash tag like hold:{event:42}:seat:9 — and failover can lose a hold. That is survivable precisely because holds are not the source of truth.
  • 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 is a virtual waiting room in front of the map, not a faster hold.

Layout

src/holds/holds.service.ts      Lua scripts + hold lifecycle  ← start here
src/seatmap/seatmap.service.ts  availability, hold orchestration, transactional confirm
db/schema.sql                   the unique constraint everything rests on
test/concurrency.spec.ts        the races, run for real
public/                         SVG map, vanilla JS, no framework

MIT licensed. Built as a public reference implementation — the venue, data, and code here are entirely synthetic.

About

Concurrency-safe seat selection: Redis holds, PostgreSQL as final arbiter, tested under 200 simultaneous requests

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages