
The problem
Selling a ticket is not a write, it is a decision. “Is there one left?” and “then it is mine” have to happen as one indivisible step. Otherwise two people press Pay on the last four tickets at the same moment, both requests read four remaining, and the venue has sold eight seats it does not have. Somebody gets turned away at the door.
Decisions
PostgreSQL, because the guarantee is the product
Availability is never read-then-decide-then-write. It is a single conditional statement: UPDATE "TicketType" SET sold = sold + n WHERE id = $1 AND sold + n <= quantity. Postgres holds the row lock for its duration and concurrent buyers serialise instead of racing. The loser matches zero rows, which becomes an error, which rolls the whole transaction back. There is no window between the check and the claim because they are the same statement, and a CHECK constraint holds the same invariant at the storage layer so a hand-written UPDATE cannot oversell either.
Full-stack Next.js, with no API server in the middle
Pages are Server Components that query Postgres directly, so the HTML that arrives already contains the events; mutations are Server Actions rather than routes. Each action re-derives identity from the session cookie and scopes its own queries, because a Server Action is a public POST endpoint and “the form only offered your own events” is not a control.
One service, and it has to fit in 512MB
There is no API to split from a frontend, so this deploys as a single long-lived Node process rather than a static site plus a server, which is what the Postgres driver wants anyway, since it holds a real connection pool that a serverless platform would multiply by the number of cold instances. The constraint that changed the most code was the memory ceiling. Every event cover is a CDN URL that has already been resized and re-encoded; the framework’s image optimizer fetched each one, decoded it, and encoded it again to AVIF at up to 3840px wide, on the web instance. That allocation does not fit in 512MB, so one image request got the process OOM-killed and took every page in flight down with it, which presents as a site that is intermittently 502, not as a site with an image problem. The fix hands the width back to the CDN, removing the work rather than making it cheaper; the responsive srcset stays real because each width is still a genuinely different URL.
Tickets are rows, not quantities
One row per person admitted, each with its own unique code. That is what lets two people holding a pair be checked in separately, and what makes a replayed scan a no-op: check-in is another conditional UPDATE, so the second scan of a code matches zero rows and the door is told so rather than admitting twice.
The hard part
Proving the guarantee instead of asserting it
Any implementation of this passes a test that buys tickets one at a time. The race only appears under real concurrency, so the test suite opens twenty simultaneous transactions against a tier of ten and asserts that exactly ten succeed and the counter lands on ten, plus a case where five buyers each want two of nine, where four are satisfied and one ticket is correctly left stranded, because you cannot sell half a pair.
The same discipline shaped the payment path, which is a simulator rather than a live gateway. What is deliberately not faked is its shape: the order is never marked paid by the function that starts the payment, because no real gateway works that way, and code that collapses those two steps has to be rewritten to go live. Stock is claimed before the gateway is called, so a slow payment cannot let someone else take the tickets mid-transaction, and one payment in eight fails on purpose, because a demo where payment always succeeds never exercises the release path, and putting unpaid tickets back on sale is the half that actually goes wrong in production.
Prices are read from the database inside the transaction and snapshotted onto the order, so repricing a tier later cannot rewrite what someone already paid. Money is integer cents throughout; a float in a currency column is a rounding error waiting for a large enough order.