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.
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 readingThree failure modes, in increasing order of embarrassment:
- Stale availability. The map renders, and is wrong immediately. Someone picks a seat that was sold while the page was loading.
- 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.
- 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.
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
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.
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.
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".
Releasing a hold checks the token first, and this matters more than it looks:
- User A's hold on seat 12 expires.
- User B acquires seat 12.
- 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.
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.
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.
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.
Honest about what this demo does not do:
- Availability is polled, and computed by pipelining
EXISTSper 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.
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.
