
THE FAIR SEAT PURCHASE — SELLING 100,000 TICKETS WITHOUT OVERSELLING A SINGLE ONE
Thousands of fans try to grab the same seat at the same time — but a seat can only be sold once. This shows how I built that ticketing system using a single DynamoDB table. With conditional writes and one transaction, every seat is sold exactly once.
The hardest word in "sell 100,000 tickets" is "exactly." Selling a lot of seats is easy.
Selling every seat exactly once — when thousands of fans are hammering the same front-row seat
in the same second — is where most systems quietly break and hand two people the same 1A.
Selling every seat exactly once — when thousands of fans are hammering the same front-row seat
in the same second — is where most systems quietly break and hand two people the same 1A.
That "exactly once under extreme concurrency" requirement is really a distributed
inventory-control and locking problem in disguise. The instinct is to reach for a lock service
or a queue. This post shows how I solved it with nothing but DynamoDB — a single table, a
conditional write, and one transaction — so the database itself decides who wins each seat, no
external lock required. I'll walk through the data model, the reservation strategy, how holds
expire and release, and where each DynamoDB operation sits in the flow.
inventory-control and locking problem in disguise. The instinct is to reach for a lock service
or a queue. This post shows how I solved it with nothing but DynamoDB — a single table, a
conditional write, and one transaction — so the database itself decides who wins each seat, no
external lock required. I'll walk through the data model, the reservation strategy, how holds
expire and release, and where each DynamoDB operation sits in the flow.
THE CHALLENGE
100,000 seats. Hundreds of fans purchasing at once. Every seat must be sold exactly once.
Once fans clear the waiting room and enter the purchasing flow, the real work begins: they
browse a live seat map, pick a section and row, and race to check out while inventory drains in
minutes. A single oversell is unacceptable — two fans can never end up with the same seat.
Temporary holds must expire if a fan walks away, and released seats must become available to the
next buyer instantly. All of this has to stay correct under extreme concurrency while still
feeling fast to every fan.
browse a live seat map, pick a section and row, and race to check out while inventory drains in
minutes. A single oversell is unacceptable — two fans can never end up with the same seat.
Temporary holds must expire if a fan walks away, and released seats must become available to the
next buyer instantly. All of this has to stay correct under extreme concurrency while still
feeling fast to every fan.
The full lifecycle to model: browse -> select -> hold -> pay -> confirm OR release on timeout.
THE CENTRAL IDEA: MAKE THE SEAT ITEM ITS OWN LOCK
The naive approach is a distributed lock: "lock the seat, check if it's free, mark it taken,
unlock." But locks mean extra infrastructure, added latency, and painful failure modes (what
happens when a server dies holding a lock?).
unlock." But locks mean extra infrastructure, added latency, and painful failure modes (what
happens when a server dies holding a lock?).
DynamoDB removes the need for all of that with one primitive: the conditional write. A
conditional write only applies if a condition you specify is still true at the instant of the
write, and DynamoDB serializes writes to a single item on the node that owns it. That means the
seat item itself becomes the arbiter of who wins — no external coordination required.
conditional write only applies if a condition you specify is still true at the instant of the
write, and DynamoDB serializes writes to a single item on the node that owns it. That means the
seat item itself becomes the arbiter of who wins — no external coordination required.
This one decision shapes the entire design.
DELIVERABLE 1 — TABLE DESIGN
I used a single-table design: one table, FairSeatPurchase, holds both seat inventory and
purchase transactions, distinguished by key prefixes and an Entity_Type attribute.
purchase transactions, distinguished by key prefixes and an Entity_Type attribute.
Seat item — keyed for concurrent lookups and atomic transitions:
- Partition key (PK): SEAT#<venue>#<section>
- Sort key (SK): ROW#<row>#SEAT#<seat>
Because a seat's identity maps to a single (PK, SK) pair, looking one up is a direct GetItem, and
every hold/confirm/release targets exactly one item — which is what lets DynamoDB serialize
contenders on it. Each seat carries Seat_Status (available | held | sold), Holder_Identifier,
Hold_Expiration, and Tier/Price.
every hold/confirm/release targets exactly one item — which is what lets DynamoDB serialize
contenders on it. Each seat carries Seat_Status (available | held | sold), Holder_Identifier,
Hold_Expiration, and Tier/Price.
Purchase transaction item — keyed PK = TXN#<fanId>, SK = TXN#<txnId>, tracking Payment_Status,
Txn_State, Amount, and Confirmation_Timestamp.
Txn_State, Amount, and Confirmation_Timestamp.
Two intentionally-scoped indexes:
- GSI1 — a sparse index (SECTION#<venue>#<section>) that only contains available seats.
Querying it returns the live seat map with no filtering, and it shrinks as seats sell. - GSI2 — a sharded index of held seats keyed on their expiry, used to sweep expired holds.
Every access pattern is a GetItem or Query — never a Scan. I designed and validated the whole
model in NoSQL Workbench.
model in NoSQL Workbench.
DELIVERABLE 2 — SEAT RESERVATION STRATEGY
This is the heart of the system. Every state change is a guarded conditional write.
Available -> held (the fairness-critical step):
UpdateItem seat
SET Seat_Status = "held", Holder_Identifier = :fan, Hold_Expiration = :now + 8min
CONDITION: Seat_Status = "available" OR (Seat_Status = "held" AND Hold_Expiration <= :now)
SET Seat_Status = "held", Holder_Identifier = :fan, Hold_Expiration = :now + 8min
CONDITION: Seat_Status = "available" OR (Seat_Status = "held" AND Hold_Expiration <= :now)
When thousands of fans fire this at the same seat, DynamoDB applies the writes one at a time. The
first flips the seat to held; every subsequent write fails the condition and returns a
ConditionalCheckFailedException. Exactly one fan wins; everyone else gets a clean, immediate
"seat no longer available." No lock, no queue, no race.
first flips the seat to held; every subsequent write fails the condition and returns a
ConditionalCheckFailedException. Exactly one fan wins; everyone else gets a clean, immediate
"seat no longer available." No lock, no queue, no race.
Held -> sold (a transaction, because two items must move together):
Confirming a purchase changes the seat (held -> sold) AND the transaction (paid -> confirmed). If
only one changed, you'd have a sold seat with no payment, or vice versa. I use TransactWriteItems
so both commit atomically — all or nothing. The seat guard also requires the confirming fan to be
the current holder and the hold to be unexpired, so a late confirmation can never capture a sale.
And sold has no outbound transition anywhere in the design — once sold, a seat stays sold.
only one changed, you'd have a sold seat with no payment, or vice versa. I use TransactWriteItems
so both commit atomically — all or nothing. The seat guard also requires the confirming fan to be
the current holder and the hold to be unexpired, so a late confirmation can never capture a sale.
And sold has no outbound transition anywhere in the design — once sold, a seat stays sold.
DELIVERABLE 3 — TEMPORARY HOLDS WITH AUTOMATIC EXPIRATION
Each hold lasts 8 minutes. If the fan abandons checkout, the seat must return to inventory.
My first instinct was DynamoDB TTL — but that turned out to be the wrong tool, and understanding
why was the biggest lesson of the project:
why was the biggest lesson of the project:
- TTL deletes the item. A seat must come back as available, not disappear.
- TTL isn't prompt — deletion can lag minutes to hours, so correctness can't depend on it.
Instead, I make expiration a condition-at-read-time rule: the hold condition (above) treats a
held seat whose Hold_Expiration has passed as available. A new fan can grab an expired seat
instantly, without waiting for any background process. Correctness lives in the write condition,
not in a job.
held seat whose Hold_Expiration has passed as available. A new fan can grab an expired seat
instantly, without waiting for any background process. Correctness lives in the write condition,
not in a job.
On top of that, I run a lightweight active sweeper (querying GSI2 for expired holds and resetting
them) purely for housekeeping — so the stored status and the seat map look tidy. Even if the
sweeper never ran, the system would still be correct. That's the trade-off, stated plainly:
passive condition-at-read-time guarantees correctness; the active sweeper adds freshness.
them) purely for housekeeping — so the stored status and the seat map look tidy. Even if the
sweeper never ran, the system would still be correct. That's the trade-off, stated plainly:
passive condition-at-read-time guarantees correctness; the active sweeper adds freshness.
STRETCH GOAL — REAL-TIME AVAILABILITY VIEW
The landing page shows every section grouped by price tier with a live availability count and
meter, powered by Select=COUNT queries on GSI1. Inside a section, the seat map renders every seat
colour-coded by live status — 🟢 available, 🟣 held, 🔴 sold — and polls so seats change
colour as other fans hold, buy, or release them.
meter, powered by Select=COUNT queries on GSI1. Inside a section, the seat map renders every seat
colour-coded by live status — 🟢 available, 🟣 held, 🔴 sold — and polls so seats change
colour as other fans hold, buy, or release them.

DELIVERABLE 4 — ARCHITECTURE: WHERE DYNAMODB FITS IN THE FLOW
The architecture that matters here is the data layer — every step of the fan's journey maps to
one specific DynamoDB operation on the single table:
one specific DynamoDB operation on the single table:
Browse a section -> Query GSI1 available seats only (sparse, eventually consistent)
View live counts -> Query GSI1, Select=COUNT per-section availability
Select + hold a seat -> UpdateItem (conditional) available -> held
Open the purchase -> PutItem (conditional) new "pending" transaction
Pay + confirm -> TransactWriteItems held -> sold + paid -> confirmed (atomic)
Abandon / cancel -> UpdateItem (conditional) held -> available
Hold expires -> condition-at-read-time an expired hold reads as available
(+ GSI2 query to sweep and reset stored status)
View live counts -> Query GSI1, Select=COUNT per-section availability
Select + hold a seat -> UpdateItem (conditional) available -> held
Open the purchase -> PutItem (conditional) new "pending" transaction
Pay + confirm -> TransactWriteItems held -> sold + paid -> confirmed (atomic)
Abandon / cancel -> UpdateItem (conditional) held -> available
Hold expires -> condition-at-read-time an expired hold reads as available
(+ GSI2 query to sweep and reset stored status)
Reading it top to bottom shows why the model is shaped the way it is:
- Browsing never blocks buying. GSI1 is sparse — it only holds available seats — so the seat
map and counts are cheap eventually-consistent Queries that don't touch the write path. - Holding is the fairness point. It's a single conditional UpdateItem on one seat item, so
DynamoDB serializes every contender there and exactly one wins. - Confirming is the only transaction. It's the one place two items must change together (seat +
payment), so it's the only place TransactWriteItems is needed — everything else stays a
cheaper single conditional write. - Releasing needs no coordination. An abandoned hold is a conditional UpdateItem; an expired one
is handled by the write condition itself, with GSI2 only used to tidy up.
To be confident the invariant actually holds, I backed the model with property-based tests that
simulate up to 10,000 fans racing for the same seat and assert it is transitioned to sold at most
once — every run.
simulate up to 10,000 fans racing for the same seat and assert it is transitioned to sold at most
once — every run.
WHAT I LEARNED
- The database can be your lock. A conditional write claims a resource exactly once — simpler,
cheaper, and safer than any external lock service. - Match the tool to the job. TTL is for cleanup, not for releasing inventory. Choosing your
source of truth (a live condition vs. a delayed background job) is the difference between
"correct" and "usually correct." - Model the questions before the schema. In DynamoDB, your access patterns dictate your keys.
Get that right and every operation is a fast key lookup.
WHERE THESE PATTERNS APPLY
The "claim exactly once, hold briefly, then confirm or release" pattern goes far beyond concert
tickets: flash sales and limited-stock drops, airline seats and hotel rooms, restaurant and
appointment booking, parking spaces and EV chargers, promo codes and software licenses — any
system where double-selling is catastrophic. The same three DynamoDB primitives — conditional
writes, transactions, and time-based expiration — give you a purchasing pipeline that stays
bulletproof under load without a single external lock.
tickets: flash sales and limited-stock drops, airline seats and hotel rooms, restaurant and
appointment booking, parking spaces and EV chargers, promo codes and software licenses — any
system where double-selling is catastrophic. The same three DynamoDB primitives — conditional
writes, transactions, and time-based expiration — give you a purchasing pipeline that stays
bulletproof under load without a single external lock.
My submission for the DynamoDB Football Data Modeling Challenge #2 — "The Fair Seat Purchase."
Live demo: https://d1ph0ldhhnu3jn.cloudfront.net
Code & data model: https://github.com/DeepikaSidda/Fair-Seat-Purchase
Code & data model: https://github.com/DeepikaSidda/Fair-Seat-Purchase
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article