Design a Reservation System
- Scope a booking system and pin the contention that drives every decision
- Walk the browse, hold, and confirm paths end to end with a clean inventory model that survives the on-sale flood
- Defend your choices on atomic holds, expiring holds, idempotent booking, and read scaling
Understanding the problem
After video, "design a booking system" is the shape Amazon reaches for most. The prompt shows up in many forms. You might reserve a seat at an event, book an appointment slot, or pull a unit of a SKU off the warehouse shelf for an order. Underneath, the plumbing never changes: a finite pool of inventory, and many people wanting the same piece of it at the same moment. The system must never hand one unit to two customers. We will design event ticketing here, because it pushes the hard part the hardest, but the model carries straight over to appointments and stock.
The problem turns on one thing, contention. A popular concert puts 50,000 seats on sale at a single instant, and a few hundred thousand fans hammer the buy button in the first minute. So you have a small, fixed supply and a sudden flood of buyers all aiming at the same rows. Browsing the catalog is enormous but easy, while the booking write is tiny in volume and hard to get right, because correctness under that flood is the entire exercise. Miss that the danger lives in double-booking and you've solved the wrong problem. Raw scale is the decoy.
Functional requirements
Propose a tight scope and let the interviewer redirect. The core is short. A user browses available inventory, holds a piece of it while they pay, and confirms the booking. The hold is the heart of the design, so name it now even though it looks like an implementation detail.
In scope
- Browse available inventory for an event
- Place a temporary hold on specific units while paying
- Confirm a booking, charging exactly once
- Release a hold automatically when payment never completes
Below the line (call it out, then skip)
- Payment processing internals (assume a payment service)
- Dynamic or surge pricing
- Seat-map rendering and the front end
- Refunds and the secondary resale market
- Search and recommendations across events
Non-functional requirements
Reason through each one and attach a target, because the booking side and the browse side want opposite things. Correctness on the booking write is absolute. We need strong consistency on inventory, so a confirmed seat is gone for everyone the instant it sells. Double-selling one seat is the failure this design exists to prevent, and we will accept rejecting a valid request before we accept selling the same unit twice. Availability has to survive the on-sale spike, when a few hundred thousand requests arrive in a minute against fifty thousand seats. And the booking path must be safe to retry, since a buyer on a flaky phone will tap "confirm" three times and must still be charged once.
The browse side gets to relax. Showing a seat as available that someone grabbed two seconds ago is fine, because the hold step will catch it and the customer simply picks again. So reads can be eventually consistent and served from caches, while writes go through one authoritative path. We design along that seam, where reads are huge and forgiving and the write is tiny and unforgiving.
- ~50K Seats / hot event. The fixed, finite supply.
- ~200K Buyers in minute 1. All aimed at the same rows.
- Strong Consistency on writes. No seat sold twice, ever.
- ~100:1 Browse : book. Why reads ride a cache.
The set-up
Core entities
Pin down what the system actually moves around before sketching a single box. Four stand out:
Event
Whatever is being booked. It carries an id and a name, plus the venue and start time. For appointments this is a provider; for stock, a product.
Seat
One unit of inventory with a status of available, held, or booked. The status field is where the entire concurrency fight happens.
Hold
A short-lived claim by one user on one seat, stamped with an expiry. It is what gives a buyer time to pay without letting them park a seat forever.
Booking
The confirmed, paid-for result. One booking points at one seat and one user, and it is the durable record of the sale.
The important split is between the inventory and everything else. A Seat row and its status are the contended core that must stay correct. So they belong in a store that can do an atomic conditional write: an update that only lands if the row still looks the way you expect. A relational database fits cleanly, and Postgres is the name everyone in the room knows. Partition the seat table by event_id. Then all of one event's seats, and all of its contention, live on one shard, the single slice of the database holding that event's rows. Booking rows are append-only history and can sit in the same database. The seat map a browser renders is read constantly and barely changes during a sale. That is exactly what a cache is for, and it never needs to touch the write path.
Amazon-internally you would reach for DynamoDB with conditional updates here, and we will use that mechanism in the deep dives. Both stores give you the same primitive, a write that fails unless a condition holds, and that one primitive holds the design together.
API
The interface is small, and the shape of it already tells the consistency story. Browsing is a plain read. Holding and confirming are the two writes, split apart on purpose so payment can happen in between without holding a database lock open for thirty seconds.
High-level design
Move through the requirements one by one, standing up the simplest thing that works before any optimizing. Flag each weak spot as it surfaces and save the polish for later. The design falls into two halves along a read/write seam. One side is a forgiving browse path that an edge cache mostly answers. The other is a narrow, strongly consistent write path where holds and bookings happen. The diagram uses color to mark each component's role: blue for people, violet for our services, and green for storage. There is no queue on the booking path, because a hold is a single fast write the database arbitrates directly. The walkthrough tags each component the same way.
1) Browse. The User calls GET /events/{id}/seats and the Seat-map cache answers nearly every time. A seat map for one event is small, a few thousand to fifty thousand rows of "id and status," so it caches in full and serves in a millisecond. We deliberately let this be slightly stale. Say the cache shows a seat as open that someone grabbed a second ago. No harm is done: the hold step is the real check and will reject the stale pick. The flood of browsers never reaches the Inventory DB, which keeps the database's scarce write capacity for the one operation that needs it. The thing we are punting on is how the cache learns about changes fast enough to feel live, which is the read-scaling deep dive.
2) Hold. This is the request that matters, and it runs the moment a user clicks a seat. The client calls POST /events/{id}/holds, and the Booking service attempts a conditional write against the Inventory DB. It flips the seat from available to held and stamps it with this user and an expiry a few minutes out. The catch: it only lands if the seat is still available. If the row already moved, the write fails and the user is told to pick again. One winner per seat falls straight out of letting the database arbitrate. The database serializes writes to a single row, so exactly one of the racing requests sees available and the rest see held. The hold buys the buyer time to pay without anyone locking a database row for the length of a checkout. The weak spot we are punting on is the hold that never resolves, the buyer who closes the tab mid-payment. The expiring-holds deep dive reclaims it.
3) Confirm. With a live hold, the user submits payment and the client calls POST /bookings. The Booking service asks the Payment service to charge the card, and only on success does it flip the seat from held to booked and write the durable Booking row. Splitting hold from confirm is what lets payment, which is slow and external, sit between two fast database writes instead of inside one long transaction. The buyer on a spotty connection will mash "confirm" more than once, so this endpoint has to charge exactly one time no matter how many duplicate requests arrive. Making the booking write idempotent, meaning safe to run twice with the same result, is its own deep dive.
4) Release. A hold ends one of three ways. The user confirms and it becomes a booking, the user cancels and DELETE /holds/{id} frees the seat at once, or the user vanishes and the timer runs out. The first two are easy. Abandonment is the interesting case, because nobody is around to clean up. So the Expiry sweeper is a background job that finds holds whose expiresAt has passed and flips those seats back to available. Without it, every abandoned checkout would silently shrink the supply until a sold-out-looking event still had empty seats. That sweeper, and the cheaper trick that often replaces it, is the expiring-holds deep dive.
Technology choices
So far every box names a kind of thing. In the room you commit to a specific tool for each and back it up, since Amazon interviewers test the concrete pick and lean on failure cases. What happens when a node dies mid-hold, or the sweeper falls behind? Each entry below gives the pick, the reason for that kind of tool, and how the design responds when it breaks.
- The Booking service runs as stateless servers behind a load balancer. No server holds a hold or a booking in its own memory, so any box can serve any request and a dying one just drops out of rotation mid-spike. All the truth lives in the inventory store below, which is what makes correctness the database's job and not the fleet's. The servers stay boring on purpose.
- The Inventory DB is DynamoDB (or Aurora/Postgres if you prefer SQL). It has to support a conditional write, an update that only commits when the row still matches an expected value. That single primitive is how we guarantee one winner per seat. Partition the data by
event_idso one event's contention lands on one partition and never spreads. The follow-up writes itself: what if that partition is a single hot event everyone wants at once? A few thousand writes a second to one partition is within a tuned store's reach. The deep dives below cut the contention further so no single row carries the whole storm. - The Seat-map cache is Redis, an in-memory store that answers reads in well under a millisecond. The seat map for one event is tiny and read by everyone, so caching it in one piece keeps the browse flood off the database entirely. When a hold or booking changes a seat, the booking service invalidates that event's cached map so the next reader refetches. The mechanism that keeps it from going badly stale is the read-scaling deep dive.
- The Expiry sweeper reclaims abandoned holds, and there are two honest ways to build it. The simple one is a scheduled job that periodically scans for
expiresAt < nowand frees those seats. The cheaper one writes the expiry as a TTL, a time-to-live the store itself watches, so the database expires the hold for you with no job to run. Either way the failure question is "what if the sweeper dies?" The answer is that holds are reclaimed lazily on read too, so a stale hold blocks nobody for long even if the sweep is late. - The Payment service is an external dependency we treat as a slow, fallible black box. Charging is the one step that talks to the outside world, so it gets wrapped in retries with an idempotency key. A charge that times out is reconciled later rather than blindly retried into a double-charge. The booking write only commits after the charge confirms, which keeps a failed payment from ever producing a booked seat.
The shape underneath is simple. One store carries all the truth and arbitrates every contended write, while a cache absorbs the reads and a background timer cleans up after people who walked away. The brand matters less than the defense. Whether you say DynamoDB or Postgres, what they want is "a store with a conditional write and a per-event partition key." The interviewer is probing whether you know why the category fits, well past which product name you memorized.
Potential deep dives
The bulk of the signal shows up in these deep dives. The interviewer fixes on one or two and bears down, and on a reservation system that is almost always concurrency first. The list below is what Amazon goes to most often, each weighed against the tiers an interviewer scores you on.
1) How do we stop two people from booking the same seat?
This is the core problem, so size the race before solving it. At the on-sale instant, picture a few hundred buyers all clicking the same front-row seat in the same handful of milliseconds. Every one of their requests reads the seat as available at nearly the same time. If the system lets all of them write, every one of them thinks they won, and you have sold one seat many times over. The fix has to make the read-then-write a single indivisible step, so exactly one request can flip the seat and the rest are told no.
Approach
The service does SELECT status FROM seats WHERE id = ?, sees available in application code, then does UPDATE seats SET status = 'held'.
Challenges
Two requests can both run the SELECT before either runs the UPDATE, so both see available and both write held. The gap between checking and acting is the classic race condition, and under an on-sale flood it fires constantly. Nobody should propose this. The value is being able to say precisely why it double-books.
Approach
Collapse the check and the change into a single statement that only lands if the seat is still free: UPDATE seats SET status='held', held_by=?, expires_at=? WHERE id=? AND status='available'. The database serializes writes to that one row, so exactly one racing request matches the AND status='available' clause and the rest update zero rows and are told to pick again. In DynamoDB this is a conditional update with the same effect. The approach is called optimistic because it assumes conflicts are rare and lets the write fail and retry rather than locking the row up front.
Challenges
It works perfectly for a single seat. The honest limit is the hot row. If hundreds of writes a second all target the one most-wanted seat, they serialize through a single row and queue up. That is fine for one seat but adds up across a venue. It also leans on every writer going through this exact conditional path, so a careless code path that updates the row unconditionally reopens the hole.
The conditional write is enough for one seat at a time. The reason interviewers push further is the group booking, four friends who want four adjacent seats as all-or-nothing, which a single-row conditional update can't express on its own.
Approach
For a group of seats, wrap the conditional writes in a database transaction so all four flip to held together or none do. In SQL, SELECT ... FOR UPDATE on the four seat rows takes a row lock. The service checks all four are free, updates them, and commits. If any one is taken, the entire transaction rolls back and the user is told the block is gone. DynamoDB offers TransactWriteItems, which applies several conditional writes as one atomic unit with the same all-or-nothing guarantee. Lock the rows in a fixed order, say ascending seat id, so two overlapping group requests can't grab seats in opposite orders and deadlock waiting on each other. The hold's short expiry caps how long any of these locks can possibly persist.
Challenges
Transactions cost more than a single conditional write and hold locks across multiple rows. So the discipline is to keep them tiny: lock, check, write, commit, with no slow work like a payment call inside. The payment is exactly why we separated hold from confirm, so the only thing inside the transaction is the inventory flip, which finishes in microseconds.
The handling contention lesson covers optimistic concurrency and row locking in full.
2) How do we reclaim holds when buyers walk away?
Holds exist so a buyer can pay without racing a clock, but most checkouts that start never finish. Suppose a hold lasts five minutes and a third of buyers abandon mid-payment. On a 50,000-seat event that is tens of thousands of seats locked up at any moment by people who left. If those holds never expire, the event looks sold out while seats sit empty, which is both lost revenue and a furious customer staring at a full map. So a hold has to carry an expiry, and something has to act on it.
Approach
Every hold row carries an expires_at timestamp. A background job runs on a short interval, finds holds where expires_at < now() and the seat is still held, and flips those seats back to available, invalidating the cached seat map as it goes. It is simple, easy to reason about, and one indexed query on expires_at finds the work each pass.
Challenges
The sweep runs on an interval, so a seat freed at second 1 of a 10-second cycle waits up to 10 seconds to reappear, which is usually fine. The real worry is the job itself: if it dies or falls behind during a spike, expired holds pile up and supply quietly shrinks until it recovers. It needs monitoring and a second instance ready to take over.
Approach
Write the hold with a TTL, a time-to-live the datastore itself enforces, so the hold record expires on its own with no job to run. DynamoDB and Redis both expire keys natively. Then make the system self-healing by reclaiming lazily: whenever a hold request finds a seat marked held but with a passed expires_at, it treats the seat as free and takes it in the same conditional write, using WHERE status='available' OR (status='held' AND expires_at < now()). So a stale hold is reclaimed the instant someone actually wants the seat, exactly when it matters. The background sweep drops to a slow backstop for seats nobody is fighting over.
Challenges
Clock skew across servers means an expiry can fire a hair early or late. Keep the hold window generous enough that a few seconds of skew never cancels someone mid-payment. And lazy reclaim has to live in the same conditional write that grabs the seat, or you reintroduce a check-then-act gap between deciding a hold is stale and acting on it.
3) How do we charge exactly once when the client retries?
Confirmation is the dangerous write, because it spends real money and travels over a network that drops responses. A buyer taps "confirm," then their phone loses signal before the response comes back. The app retries, and now two identical POST /bookings requests hit the server. The first may have already charged the card and booked the seat. Without protection the second charges again and the customer is billed twice for one seat, which is the worst possible outcome for a booking system. The write has to be idempotent, meaning running it twice produces the same single result as running it once.
Approach
The client retries POST /bookings on a timeout, and the server processes each request it receives at face value.
Challenges
A retry that arrives after the first request already charged the card produces a second charge and, depending on timing, a second booking attempt. The customer sees a double charge and the system has no way to tell a genuine new booking from a duplicate of an old one. This is the default behavior you must design away.
Approach
The client mints a unique idempotency key once per checkout attempt and sends it on every retry of that same booking. The server keeps an idempotency table keyed by it. On the first request it inserts the key with a conditional write (the same one-winner primitive from deep dive one), does the work, and stores the result against the key. A retry carrying the same key finds the row already there and returns the stored result without charging again. Pass that same key through to the payment service, which has its own idempotency on charges, so even the charge collapses to one. The booking becomes a function of the key: the same key always yields the same single booking, no matter how many times it arrives.
Challenges
The first request and its retry can race if they arrive nearly together. So the key insert must be the atomic conditional write, letting exactly one proceed while the other waits and then returns the winner's result. Keys need a retention window long enough to cover any realistic retry, then can be aged out. And the key must be stable across retries, so the client generates it once per attempt rather than fresh on each tap.
The async processing lesson covers idempotency keys and exactly-once semantics in more depth.
4) How do we serve the browse flood without touching inventory?
Now the easy-but-large side. For every person who books, a hundred or more are browsing the seat map and refreshing it. At a few hundred thousand concurrent browsers on a hot on-sale, that is a wall of reads that would bury the inventory database if each one hit it. The requirements give us room here: browse reads are allowed to be a little stale, since the hold step is the real arbiter. So we can serve them from copies and never risk correctness.
Approach
Keep each event's seat map in Redis and serve every browse from it, so the reads never reach the inventory database. When a hold, booking, or release changes a seat, the booking service deletes that event's cached map so the next read repopulates it fresh. For a single event the map is small, so a refetch is cheap.
Challenges
During an on-sale, seats change many times a second, so invalidate-on-every-change can thrash. The map is deleted and refetched constantly, and a herd of browsers can all miss at the same instant and stampede the database to rebuild it. It also leaves a brief window where the cache is empty right when traffic peaks.
Approach
Point browse reads at read replicas of the inventory database, copies that take the read load off the primary while the primary handles only the contended writes. In front of them, cache the seat map with a short TTL of a second or two rather than invalidating on every change. The map then refreshes on a steady cadence and never thrashes under a storm of updates. A browser might see a seat one second out of date, which the requirements already permit. To stop a cache miss from sending a herd at the replicas, coalesce the rebuild so one request repopulates the map and the rest wait for it. The write path stays untouched, so all this read scaling never weakens the one-winner guarantee.
Challenges
Replicas lag the primary slightly, adding to the staleness, but the hold step still reads through to the authoritative row, so a stale browse only ever costs a user a second pick, never a wrong booking. The short TTL is a dial: shorter feels more live but hits the replicas more often, longer is cheaper but staler.
The scaling reads lesson generalizes replicas, caching tiers, and request coalescing beyond booking.
What is expected at each level
That's a lot of surface area. Here's how much you need to land, by the level you're interviewing for. Remember that at Amazon the design round mostly decides your level rather than the hire, and a weak one is the common reason a strong loop gets down-leveled.
L4 (SDE I)
Mostly breadth, roughly 80/20 breadth to depth. New-grad loops often skip system design entirely. If you get a lighter version, aim for a working end-to-end skeleton: a browse read, a hold, a confirm, and an inventory store. You should know that two people must not book the same seat and gesture at a database check stopping it, even if you don't reach the full conditional-write argument. The bar: a complete flow you can explain, with the interviewer helping you find the race.
L5 (SDE II)
Breadth, then real depth, roughly 60/40. This is the band where the design round most decides your level. You should reach the conditional write for one-winner-per-seat on your own, separate holds from confirms, add expiring holds, and make the booking idempotent without being walked there. Raise the contention problem and the read/write asymmetry before the interviewer does, and defend the inventory store's partition key. The bar: a near-optimal design where you found the concurrency bottleneck yourself and closed it.
L6+ (SDE III / Senior)
Expect breadth that keeps widening with real depth underneath, roughly a 40/60 split. Expect questions on the dead-node-mid-write failure and on group bookings across multiple seats and shards. Expect them on multi-region inventory and how you keep one authoritative copy of a seat's status. And expect them on how the system degrades when the sweeper or a replica fails. You're carrying the conversation and reasoning about how the design behaves once things go wrong, long after the easy path stops being interesting. The bar: you lead, and the interviewer adjusts your course rather than taking the wheel, with each failure mode answered on the design's own terms.
Key Takeaways
- ✓The hard part is contention rather than throughput: a small fixed supply against a flood of buyers, where the one rule is that no seat is ever sold twice.
- ✓A conditional write (optimistic concurrency) gives one-winner-per-seat; wrap multiple seats in a transaction with ordered locks for atomic group holds.
- ✓Holds carry an expiry reclaimed by a TTL plus lazy reclaim on read, so abandoned checkouts free their seats even when the background sweeper is down.
- ✓Make the booking write idempotent with a client-generated key so retries charge exactly once, and scale browse reads off replicas and a short-TTL cache while the write path stays strongly consistent.
Amazon interview guideKnow what Amazon asks before you walk in.
19 coding · 6 system design · 44 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.