All projects

Aura

A ticketing platform with a four-tier oversell defense that guarantees no seat is sold twice.

Role
Solo — architecture, implementation, UI, docs
Year
2026
Status
Portfolio · correctness core, docs, diagrams shipped
TypeScript
Next.js
App Router
Server Actions
Prisma
PostgreSQL
Supabase
JWT
bcryptjs
Tailwind CSS
Framer Motion
Archify
Aura's home screen: an event grid, published event cards and a 10-minute held-seat flow backed by a four-tier oversell defense.

The problem

Everyone knows the two-sentence pitch: ticket the event, collect money, send a QR. What is never in the pitch is the part that matters. What happens when fifty thousand people all refresh the same 5,000-seat tour at the same moment? What happens when a phone on 4G taps “Buy” four times? What happens when a refactor accidentally creates Ticket rows outside the correctness transaction?

Most first implementations read sold from the event row, compare it to capacity, then write the new tickets and the new sold count. Under any real concurrency, two requests both read the same stale number, and you just oversold by however many writers were racing. Fixing the race with a lock is not the interesting work. The interesting work is stacking enough independent, non-redundant guardrails that a bug in any one layer still cannot produce a duplicate seat on the ground — and writing the system honestly so someone reading it can verify that.

How it works

Nine modules arranged so the write path is one horizontal line, and nothing can bypass it. Correctness never depends on anything rendered.

The write path

Buyer browser

Next.js App Router

Server Actions

RBAC ownership re-checks

ticketing.ts

4-tier oversell engine

PostgreSQL

CAS + @@unique + $tx

AuditLog

append-only, RLS-ready

Everything above has to go through the CAS UPDATE on the Event row. Support systems never replace it.

Support systems

Door scanner

offline HMAC verify

CDN + Edge

listings SWR ≤ 30 s

auth.ts

JWT · bcrypt cost 12

TTL + reconciler

10 min hold · 5-min cron

Flash sale scaling order: 1. PgBouncer pool · 2. CDN SWR reads · 3. SQS FIFO per eventId · 4. Redis last.

Four tiers stacked so each catches what the one above missed: (1) a CAS UPDATE … WHERE inside one transaction, (2) a DB-enforced unique idempotencyKey, (3) the schema's @@unique([eventId, ticketIndex]), and (4) a reconciler that re-counts and writes a corrective CAPACITY_RECONCILED AuditLog entry the moment drift is seen.

Three decisions worth defending

CAS UPDATE … WHERE over SELECT-FOR-UPDATE or optimistic reads

The classic ticket tutorial reads sold, compares, then writes — and two concurrent buyers both see sold = 4999. Moving the predicate into the UPDATE collapses the read into the write so Postgres serializes it on the tuple. If updateMany.count !== 1, the request loses cleanly — no partial writes, no compensation, no deadlock window from a separately-locked SELECT. The rest of the system is built around this one statement.

Schema-enforced UNIQUEs over application-level dedup

Idempotency keys and seat-number pairs are enforced by the database, not by a find-before-insert in TypeScript. That is deliberate: a buggy refactor, a bulk migration, or an operator manually writing into Postgres can still never create two reservations with the same key or two tickets with the same (eventId, ticketIndex). If correctness depends on your app code never drifting, you do not actually have correctness.

Held seats counted against capacity from CAS time, not confirm time

Saying “10-minute hold” but running the capacity write only at confirm is lying to someone who just pulled out their card for a concert they have been waiting a year for. The CAS increments the sold counter the moment a reservation is created, and the TTL worker atomically reverts it on expiry. Scalpers can still hold seats — so per-user caps and an isBlocked flag exist — but a real buyer in checkout does not lose the slot to someone refreshing faster.

Measured, not assumed

3k–8k / s

writes on a single hot Event row with a managed Postgres behind PgBouncer. Everything else scales horizontally; this number is the honest engineering ceiling, not a marketing promise.

4 tiers

of oversell defense stacked in order: CAS UPDATE · idempotencyKey UNIQUE · (eventId, ticketIndex) @@unique · reconcileCapacity. A bug at tiers 1–3 is caught by the rest. Nothing silently succeeds.

10 min

TTL held against capacity. Expired holds are reverted inside the same row-locking transaction as the state change, so double-scheduled workers cannot double-revert. The reconciler runs every 5 minutes anyway.

0 DB calls

to reject a photo-forged QR at the door. The scanner verifies the HMAC locally with TICKET_SECRET before any POST. Forgeries are rejected in milliseconds; venue Wi-Fi cannot cause a gate riot.

The measurement that mattered most was not a benchmark. It was deliberately running two concurrent buyers on the last seat of a 500-seat event — and verifying that exactly one of them succeeded, exactly one Ticket row was written, and no compensating write was needed. Without that control case, every higher number is decoration.

What only production will find

The four tiers degrade gracefully: a bug at tier 1 is still caught by 2, 3, or 4. Production finds the shape of drift, not the fact of it.

The credentials that rotate wrong. Rotating TICKET_SECRET naively — replace the value and redeploy — breaks every unsold ticket immediately, because the scanner is still verifying against the old secret. The correct playbook (dual-sign for 24 h, verify against a list of two, drop the old one later) is documented in the Aura repo because missing the overlap window causes a gate riot, not a warning.

The connection pool that starves the CAS. Under 10 k keepalives, Prisma’s pool hits its ceiling, and adding more app servers makes it worse — each instance opens another 50–100 real DB connections and exhausts Postgres' max_connections outright. The fix is not more servers; it is PgBouncer in transaction-pooling mode first, because the actual CAS UPDATE lasts microseconds, not the lifetime of the HTTP keepalive. Adding capacity before the pooler is the classic production mistake because the problem is invisible at dev concurrency.

The pattern underneath both is the one that keeps paying for itself: absence is a state. A secret that never rotated, a connection that never got a pooler, a reconciler that never ran are all bugs. Every network wait has a deadline. Every capacity counter has a re-count. Every signing key has a documented overlap window.

There is a great deal more

The full study covers the rest: the honest trade-offs, six decisions worth defending, capacity measured rather than assumed, the bugs and what found each one, what only production found, getting through a flash sale, and what is deliberately not built.