SysPad › Interview practice › Design a hotel reservation system
Design a hotel reservation system
The interviewer says: "Design a hotel reservation system." Guests search for hotels with rooms for some dates, then book one.
A guest books a room type, not a room number. The hotel gives out room numbers at check-in.
Guests can cancel. Partners ask for availability through an API: travel agents, and metasearch sites that compare hotel prices across sellers.
We sell up to 110% of each room type per night, because some guests never arrive. That is overbooking, and we never pass the limit.
A search must answer in under 50 ms on our servers. A booking must be held in under 100 ms and confirmed in under 5 seconds.
We never lose a confirmed booking. Search may show slightly old data: usually under a second, never over 5 minutes.
Loyalty points, room numbers and rate management are out of scope.
How we work it: Interview ready means we prove no room is ever sold twice, and we find which wall comes first when searches or bookings surge.
Do the numbers before the boxes
An interviewer gives a scale, not a load. If we draw before we do the math, we cannot say why any box has its size.
We size for a group about Hilton's size: 8,400 hotels and 1.25 million rooms. Hilton had 8,447 and 1,268,206 at the end of 2024.
US hotels were 63% full in 2024. So 1.25 million x 0.63 = 787,500 rooms are sold each night.
A stay is about 2 nights, so 787,500 / 2 = 393,750 stays start each day.
About one booking in 5 is cancelled. So 393,750 / 0.8 = 492,188 bookings a day, and 98,438 cancellations.
Per second on average, that is 492,188 / 86,400 = 5.7 bookings.
We take a peak factor of 2.2: the busiest hour carries 2.2 times the average. So 5.7 x 2.2 = 12.5 bookings a second at peak.
Cancellations at peak: 98,438 / 86,400 x 2.2 = 2.5 a second.
Published look-to-book ratios run from about 100 to 20,000 searches per booking. We take about 200, our choice for a brand's own site and app.
We measure load in requests per second, and round the entry to 2,500. Writes stay exact, so searches are 2,500 - 12.5 - 2.5 = 2,485.
That is 2,485 / 12.5 = 198.8 searches per booking, about 200. Searches are 99.4% of the load.
Bookings cannot grow ten times. At 100% occupancy and 1-night stays, the rooms allow at most 1.25 million / 0.8 = 1.56 million bookings a day.
At the daily peak, that is 1.56 million / 86,400 x 2.2 = about 40 a second. It caps a day, not a burst.
Check in: Close the page and derive 2,500 requests per second from 1.25 million rooms. Then say why bookings cannot grow ten times.
Show an answer
1.25M rooms x 63% = 787,500 a night; / 2 nights = 393,750 stays a day; / 0.8 = 492,188 bookings. / 86,400 x 2.2 = 12.5 a second.
Cancellations: 2.5 a second. Round the entry to 2,500, so searches are 2,500 - 15 = 2,485: 198.8 per booking, about 200.
Bookings are capped by rooms: at 100% occupancy and 1-night stays, 1.56M a day, about 40 a second at the daily peak. Searches have no such cap.
Searches on readers, bookings on the writer
Searches are 99.4% of requests, and bookings must be exactly correct. One database must serve both, and searches must not slow bookings.
Guests reach the Hotel API through a load balancer. Each copy of the API is a task with one vCPU, a virtual processor.
A request costs about 1 ms of CPU, mostly building 20 hotels with their prices. So one task serves 1,000 a second.
The API uses auto-scaling, keeping each task near 70% busy. 2,500 / (1,000 x 0.7) = 3.6, so 4 tasks run, at 62.5%.
The database is Aurora PostgreSQL: one writer and two read replicas, or readers. Aurora sends reads to the readers and writes to the writer.
A search reads 20 hotels x 4 room types x 2 nights = 160 rows. We assume 0.4 ms per query plus 22.5 µs a row: about 4 ms of CPU.
Both are our assumptions, not measured. Halve the 22.5 µs, and the two readers take about 7,300 searches a second instead of 4,000.
One instance has 8 vCPUs, so it serves 8 x 1,000 / 4 = 2,000 searches a second.
2,485 / (2,000 x 0.7) = 1.8, so we run 2 readers.
The writer takes 12.5 bookings, 2.5 cancellations and 12.5 payment confirmations: 27.5 writes a second. That is 1.4% of it.
The writer is as big as a reader anyway, because a reader becomes the writer if the writer fails.
Stage 1 runs this design in SysPad at 2,500 requests per second. Each card is a part of the system, and each arrow carries requests.
Read the four tiles. "First to saturate" reads "Nothing at this load". Its note names the Hotel API as busiest, with the database close behind.
A part saturates when it gets more requests than it can serve.
The latency tile shows 7ms. The tile shows the slowest path, which here is a search.
That is 1.5 ms in the load balancer, 1 ms in the API and 4 ms on a reader: 6.5 ms, shown as 7ms.
The readers take 4,000 searches a second. Searches are 0.994 of the entry, so they run out at an entry of 4,000 / 0.994 = 4,024.
Press 4,200. The Inventory database saturates: 4,200 x 0.994 = 4,175 searches a second, against 4,000.
Two readers at 62% also cannot lose one. If one fails, the other gets 2,485 a second against 2,000: 124%, until a new one starts.
The trade-off:
We pay for a writer that runs at 1.4%. That is the price of a fast failover.
Searches and bookings share one cluster but never one instance, so a burst of searches cannot slow a booking.
Stage 1: Searches on readers, bookings on the writer, 2,500 a second
- Load: 2,500 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $6,168/mo
Check in: Say how you got 2 readers, starting from 2,485 searches a second. Then say where they run out.
Show an answer
160 rows x 22.5 µs + 0.4 ms = 4 ms a search, so 8 vCPUs serve 2,000 a second. 2,485 / (2,000 x 0.7) = 1.8, so 2 readers.
They run out at an entry of 4,000 / 0.994 = 4,024 requests a second, where searches pass 2 x 2,000 = 4,000.
Hold the rooms, then pay behind a queue
A card check goes to the card network and the bank, and takes about a second. A guest should not wait on it to know the rooms are theirs.
A booking runs one short transaction on the writer. It reserves each night, and saves the booking with the status "held".
That makes the booking a hold: the rooms are the guest's for 15 minutes, while we check the card.
The API then puts a payment job on a queue and answers 202 Accepted. A queue holds each job until a worker takes it.
202 Accepted tells the guest we have the booking and will finish it later.
A Payment worker takes the job. It calls Stripe once, to save and check the card, with a call named a SetupIntent.
Then it marks the booking confirmed, or releases the rooms. We charge the room later, at the cancellation deadline or at checkout.
We assume a SetupIntent takes about 1 second. Stripe publishes no latency figure.
Stripe sets three limits by default: 100 calls a second per account, 25 a second per endpoint, and 50 calls open at once.
The endpoint limit binds first. 50 open, less 10 for checkout charges, allows 40 a second, and the account allows 100.
So 12.5 bookings a second use 12.5 / 25 = 50% of the endpoint limit, on a normal day.
A worker task keeps 25 calls open, and a call takes 1,005 ms with the confirm write. So a task makes 25 / 1.005 = 24.9 calls a second.
Auto-scaling runs the worker at 2 to 10 tasks.
The latency tile never includes the payment. The queue cuts it off, so a booking answers as fast as its transaction.
At the 2,500 load, the cost tile shows $6,168/mo. The database is 47% of it.
Another 43% is egress: data sent out of AWS to the internet. Here those are our answers to guests and partners, about 5 KB a search.
Every cost tile prices the peak rate as if it ran all month. Load balancer, queue and egress charges follow traffic, and so does the API's task count.
The average rate is the peak / 2.2. So an average month of Stage 1 costs about $4,474. Comparisons between stages hold either way.
Press 5,500. Stripe saturates as well as the database: 0.5% of 5,500 is 27.5 calls a second, against 25.
The trade-off:
Card fees are a share of the room price, set by the payment contract. They are far larger than this bill, and they are not part of it.
The guest waits a second or two on "confirming". In exchange, a slow bank never slows a search or a hold.
Stage 1: Searches on readers, bookings on the writer, 2,500 a second
- Load: 2,500 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $6,168/mo
Check in: Which part runs out first as load grows, and at what load? Then name the next wall.
Show an answer
The readers, at an entry of 4,024 a second, where searches pass 2 readers x 2,000 = 4,000.
The payment provider next, at 5,000: 0.5% of 5,000 is 25 calls a second, its per-endpoint limit.
Never sell one room twice
The interviewer asks: Two guests press Book for the last room at the same moment. Who gets it?
Every hotel interview reaches this question. Two bookings read "1 room free" at once, and both write.
If each one reads, then checks, then writes, both see a free room. Both succeed, and one guest has no bed.
This is a question about one row, not about load. So we reason about it by hand, and no stage goes with this beat.
The inventory has one row per hotel, room type and night. It holds the total rooms and the number reserved.
In PostgreSQL, an UPDATE locks the row it changes until the transaction commits. All three answers take that lock.
So the question is how long the lock lasts, and what runs while it is held. A row is a few hundred rooms, so its throughput is rarely the problem.
We price the longest hold on this page, a 4-night convention stay. That is 4 updates, 1 insert, and a commit that waits for 4 of 6 storage copies: about 5 ms.
An everyday 2-night stay is 2 updates and 1 insert, about 3 to 4 ms.
Answer 1 is pessimistic locking. The booking reads its rows with SELECT FOR UPDATE, the API checks the counts, then it updates and commits.
The lock spans the read, a round trip to the API, the writes and the commit: about 1 + 5 = 6 ms. One row takes about 1,000 / 6 = 170 bookings a second.
Its trap is the gap: app code runs while the lock is held. Put the 1-second payment call inside, and one row takes about 1 booking a second.
Answer 2 is optimistic concurrency. The booking reads a version number with no lock, then updates only where the version still matches.
Its lock spans only the update and commit, about 5 ms. Zero rows changed means another booking won, so this one retries from the read.
With a 5 ms window, about 1 - e^(-0.005 x attempts per second) retry. That is 2.5% at 5 attempts a second on one row, and 63% at 200.
Answer 3, our choice, is a conditional update. One UPDATE adds 1 to reserved on each night, only if reserved + 1 <= floor(total x 1.10).
We commit only if every night changed one row. A CHECK constraint on the table backs the condition up.
Its lock spans one statement per night and the commit, about 5 ms, so one row takes about 200 a second.
A second guest on that row waits for the commit, at most about 5 ms. PostgreSQL then checks the condition again, changes 0 rows, and we answer 409.
The 170 against 200 is not the real difference. The difference is that no app code runs inside the lock, and nobody retries.
Every answer updates nights in date order, so two bookings cannot deadlock.
Step through the figure to see the race, and what each answer does with it.
The trade-off:
Pessimistic locking lets slow code into the lock. Optimistic concurrency makes the loser retry. The conditional update makes the loser wait one short commit, then fail.
The rule lives in one SQL condition, so every path that writes inventory must use it.
Two guests, one room, three answers
- Read, check, then write leaves a gap. Both guests see one room free, and both book it.
- A pessimistic lock makes the second guest wait while the API checks. At about 6 ms a booking, one row takes about 170 a second.
- Hold the lock across the payment call, and the row takes 1 booking a second. Waiters pile up, each holding a connection.
- Optimistic concurrency takes no lock while it reads, but the loser retries. At 200 attempts a second, 63% retry.
- One statement checks and writes together. The second guest waits about 5 ms, then gets 409, with no retry.
Check in: Two guests book the last room at once. Say what each of the three answers does, and why you choose the conditional update.
Show an answer
All three hold the row lock until commit. They differ in how long it lasts, what runs inside it, and what the second guest does.
Pessimistic: SELECT FOR UPDATE, then the API checks: about 6 ms, about 170 bookings a second per row. With the payment call inside, about 1 a second.
Optimistic: no lock while reading, a 5 ms lock to write, but retries under contention: 2.5% at 5 attempts a second, 63% at 200.
Conditional update: the limit in the WHERE clause, backed by a CHECK. About 5 ms, about 200 a second. No app code inside the lock and no retries: the second guest waits about 5 ms, then gets 409.
Ten times the searches, the same bookings
The interviewer asks: We list our hotels on metasearch sites and open an API to travel agents. What breaks?
Metasearch sites ask for our prices on every search their users run. Searches grow ten times, to about 2,000 per booking.
Bookings stay at 12.5 a second, because the rooms are the same rooms. The entry is 24,985 + 12.5 + 2.5 = 25,000.
The readers still take 4,000 searches a second. Searches are now 0.9994 of the entry, so they run out at 4,000 / 0.9994 = 4,002.
The writer is at 1.4%. Why not send searches to it as well?
It has about 2,000 - 27.5 = 1,973 reads a second to spare. That moves the wall from 4,000 to about 5,973 searches a second, against 24,985.
Why not more readers? Aurora allows 15. At 2,000 each that is 30,000 a second, and 24,985 / 30,000 = 83%.
Press 2,500 on Stage 2. With the new mix at the old load, nothing saturates.
Press 25,000. The Inventory database saturates, and the latency tile shows "≥ 19ms", a lower limit. The reader on the search path is saturated.
Read the stage's warnings. The readers are at 625%: 24,985 reads a second against 4,000 across 2 readers.
The cost tile shows $27k/mo, up from $6,168/mo. Most of the rise is egress: ten times the searches send back ten times the answers.
Egress is now $19,336 a month, about 70% of the tile. In a read-heavy design, the answers we send can cost more than the machines.
The trade-off:
Fifteen readers and the writer cost 16 x $973.44 = $15,575 a month, and they leave little room to grow.
Every search reads 160 rows. Ten times the searches is ten times the reads, unless something answers them first.
Answer size is a cost lever too. At 25,000 a second, each KB cut from a search answer saves about $3,090 a month.
Stage 2: Metasearch traffic on the same design, 25,000 a second
- Load: 25,000 req/sec
- First to saturate: Inventory database
- Estimated cost: $27k/mo
- Latency past saturation is a lower bound, not an estimate. Inventory database is saturated on the critical path, where the queueing model stops tracking a real queue - the true figure is higher by an amount this model cannot know. Relieve the bottleneck to get a latency number worth reading.
- Inventory database: Readers 625% used: 24,985 reads/s against 4,000 across 2 readers (the writer takes no reads while readers exist).
Check in: The writer is at 1%. Would sending searches to it fix this stage? Give the number.
Show an answer
No. It adds about 1,973 reads a second against a gap of 20,985.
An availability cache in front of the readers
Searches can be a little stale, and many guests ask about the same hotels and nights. That is what a cache is for.
We add an Availability cache. It holds one key per hotel and night: rooms free and price for each room type, about 120 bytes.
8,400 hotels x 365 nights = 3.07 million keys, about 368 MB. All of it fits in memory.
A search asks the cache for its 20 hotels x 2 nights = 40 keys in one call. It asks the database only for the keys the cache lacks.
After a booking or a cancellation commits, the API writes the new counts into the cache. That is write-through. Keys also expire after 5 minutes.
The booking still checks its rows on the writer. So the cache is a hint, never the judge.
Four numbers follow, each in its own unit. First, the hit rate: the share of key reads the cache answers.
A key lives 300 seconds. If it is read λ times a second, one read in 1 + 300λ misses.
We assume 80% of key reads ask for the next 60 nights, because most stays are booked weeks ahead. It is the input to change first.
Near nights: 504,000 keys share 800,000 reads a second, so λ = 1.59 and 0.21% miss. Far nights: λ = 0.078, so 4.1% miss.
Blended: 0.8 x 0.21% + 0.2 x 4.1% = 1% of key reads miss. The hit rate is 99%.
Second, searches: 1 - 0.99^40 = 33% of searches miss at least one key, about 8,250 a second. Each sends one small query of about 0.51 ms.
Third, database work, counted in full searches of 4 ms of reader CPU. The small queries cost 0.33 x 0.51 = 0.17 ms per search: 4.2%, about 1,050 a second.
Fourth, the plan: 5%, or 1,249 full searches a second, which one reader takes at 62%. The margin covers a cache restart or a deploy.
The cache card types 95%, the share of work it removes. So the database counts its search work as 1,249 full searches a second.
Step through the figure to see why a stale key can never sell a room twice.
The latency tile still shows 7ms, but it is now a hold or a cancellation.
That is 1.5 ms in the load balancer, 1 ms in the API and 4 ms on the writer: 6.5 ms.
The tile shows the slowest path, weighted by how often each path runs. A search takes 1.5 ms, then 1 ms, then 0.2 ms at the cache.
The database adds its 4 ms only for the 5% of search work the cache leaves. So a search weighs 1.5 + 1 + 0.2 + 0.05 x 4 = 2.9 ms.
By hand, a search whose 40 keys all miss takes 6.7 ms. A search that finds every key takes 2.7 ms.
Press 25,000 on Stage 3. Nothing saturates. The cost tile shows $28k/mo, and an average month is about $16,330.
Say it out loud: "About $16,000 a month at metasearch scale, and nearly two thirds of it is the answers we send back."
Press 52,000. Only the Payment provider (Stripe) saturates: 0.05% of 52,000 is 26 calls a second, against 25.
The trade-off:
The cache and the database cost $3,424 a month. Fifteen readers would cost $15,575, so the cache saves $12,151 a month.
The cache removes database work, not answers. Every search still sends its 5 KB back, so egress is the same as on Stage 2.
Network, not memory, sets the node size. For each search, the cache sends back 40 values of about 45 bytes: 1,800 bytes, or 1.76 KiB.
24,985 searches a second x 1.76 KiB = 0.36 Gbps out. Data in and data out have separate limits, so that is 19% of a cache.r7g.xlarge's 1.876 Gbps baseline, its steady rate.
A lost cache write leaves a key wrong for up to 5 minutes. A stale "free" shows a room that is gone, and the booking gets 409.
A stale "sold out" hides a room that came back. That loses a sale, so cancellations write through too.
The cache is a hint, the database decides
- A search asks for 40 hotel nights, and 99% of key reads hit. Only the missing nights reach the database: about 4% of the old work.
- After a booking commits, the API writes the new count into the cache. Keys also expire after 5 minutes.
- A stale key can show a room that is gone. The booking checks the row on the writer, so the guest gets 409, never a double booking.
Stage 3: An availability cache in front of the readers
- Load: 25,000 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $28k/mo
Check in: A search shows a room as free, but it sold a second ago. Say what happens when the guest books it.
Show an answer
The conditional update finds no room, the transaction rolls back, and the guest gets "sold out" with alternatives.
The cache is a hint; the database decides.
A convention opens its room block
The interviewer asks: A convention opens its room block at 10:00. What breaks first?
About 60,000 people share a room block in 40 hotels. When it opens, 31,000 attendees have the booking page open.
The page refreshes every 5 seconds: 31,000 / 5 = 6,200 more searches a second.
30,000 book in the first 10 minutes: 30,000 / 600 = 50 more bookings a second, plus the everyday 12.5.
That is 2% of the 1.56 million bookings a day the rooms allow, packed into 600 seconds. The daily cap does not limit a burst.
The entry is 25,000 + 6,200 + 50 = 31,250.
Our database has room: the writer runs at 6.4%, and the readers at 39%. The first wall is Stripe's 25 calls a second per endpoint.
That is the senior answer. A rush on a known date hits the limit we do not own before the parts we do.
That limit was already at 50% on a normal day, and it takes weeks to raise.
Design facts we reason about by hand. 37.5 of every 62.5 calls a second get HTTP 429, too many requests, and the worker retries them.
Pacing alone is not enough. A worker that sends exactly 25 a second falls behind by 62.5 - 25 = 37.5 a second.
A job made t seconds into the rush waits 37.5t / 25 = 1.5t. So our 5-second promise holds only for the first 3.3 seconds.
A booking in the middle of the rush waits 1.5 x 300 = 450 seconds: 7.5 minutes on "confirming". That is the real failure.
The last job waits 900 seconds, the length of a hold. Only the last 0.7 seconds of the rush expire: 0.7 x 62.5 = about 40 holds.
There is no margin. If the rush ran 12 minutes, 120 x 62.5 = 7,500 holds would expire.
The hot row is not the wall either. An attendee books the convention's 4 nights: 4 updates and 1 insert, about 5 ms a hold.
Say the main hotel takes 20% of the rush: 10 bookings a second over 2 room types, so 5 a second on each night's row.
Each row is locked 5 x 5 ms = 25 ms a second, 2.5% of the time. With 1,000 rooms of a type, it sells out in 1,000 / 5 = 200 seconds.
After that, each attempt waits one short commit, finds no room, and gets 409.
Unless the lock is held across the payment call. Then the row takes 1 booking a second, and waiters grow by 5 - 1 = 4 a second.
After a minute, about 240 bookings wait on one row, each holding a database connection.
Step through the figure to watch the rush meet the limit.
Press 31,250 on Stage 4. The Payment provider (Stripe) saturates: it gets 62.5 calls a second against 25.
Read the stage's warnings. The Stripe line begins: "Exceeds the configured Stripe rate limit (25 rps)."
The latency tile still shows 7ms: it is a hold, and payment runs behind the queue. The guest sees "held" in milliseconds.
Press 10,000. A preset scales the whole load: 0.2% of 10,000 is 20 calls a second, which fits under 25.
The trade-off:
The cost tile rises from $28k/mo to $33k/mo, because each tile prices this peak as if it ran all month. The rush lasts minutes.
The real cost is time: a mid-rush guest waits 7.5 minutes for "confirmed". Never hold a lock across a network call, or one slow bank stalls a whole room type.
The rush meets the payment limit
- On a normal day, bookings use half of Stripe's 25 calls a second per endpoint.
- In the rush, bookings jump to 62.5 a second. Our database is calm; the limit we do not own breaks first.
- Paced at 25, the queue reaches 22,500 jobs, and a mid-rush booking waits 7.5 minutes. Only the last 0.7 seconds, about 40 holds, expire.
- Weeks ahead, ask Stripe for three limits: 100 a second on the endpoint, 150 on the account, 150 open. Pace the worker to 99.5, and the rush fits inside the hold.
Stage 4: A convention opens its room block, 31,250 a second
- Load: 31,250 req/sec
- First to saturate: Payment provider (Stripe)
- Estimated cost: $33k/mo
- Payment provider (Stripe): Exceeds the configured Stripe rate limit (25 rps). Excess calls are throttled (HTTP 429) - request a rate-limit increase.
Check in: The payment provider is red, but the latency tile reads 7ms. Why?
Show an answer
The tile is a hold. Payment runs behind the queue, so the guest's "held" answer stays fast, and the 429s happen to the worker, out of the guest's sight.
Ask for the limit early, then pace to it
We cannot buy past Stripe's limit on the day. But the convention date was known months ahead, so we plan for it.
Stripe asks for at least 6 weeks' notice for a large increase. We ask for three limits.
The SetupIntent endpoint goes from 25 to 100 calls a second. The account goes from 100 to 150, and calls open at once from 50 to 150.
Why the account too: at 31,250 it carries 62.5 + 10 checkout charges = 72.5 a second. With the worker at its cap, 99.5 + 10 = 109.5, more than 100.
Cancellations add no calls: a pay-at-hotel booking took no money, so there is nothing to refund.
Why the open calls: 62.5 calls a second of 1 second each are 62.5 open, plus 10 for checkout charges. That is 72.5, more than the default 50, and 110 with the worker at its cap.
With 150 open, calls of 1 second allow (150 - 10) / 1 = 140 a second. So the endpoint's 100 binds.
Then we pace the worker. Its maximum goes from 10 tasks to 4: 4 x 25 calls open = 100 open, at 1.005 seconds each, 99.5 a second.
The worker can never send more than the endpoint accepts. With the account raised to 150, the account never refuses it either.
So a bigger rush waits in our queue, inside the hold, not in a retry loop.
At 52,000, bookings are 104 a second: a convention twice this size, 62,400 bookings in 10 minutes, 4% of a day's cap.
The queue grows by 104 - 99.5 = 4.5 a second: 2,700 jobs after 10 minutes. Each waits at most 2,700 / 99.5 = 27 seconds more.
The worker saturates at 99.5 / 0.002 = about 49,750 requests a second.
Press 31,250 on Stage 5. Nothing saturates. The cost tile shows $33k/mo, the same as Stage 4: the fix costs six weeks of notice.
Press 52,000. The Payment worker saturates, on purpose: it is the pace. The Stripe card turns red because it gets 99.5 of its 100 calls a second.
That is under its limit, so the result does not name Stripe. It is the design working: the worker sends at the pace, and the queue holds the rest.
The trade-off:
A provider's limit is a wall we cannot buy past on the day. We find it in their documents, ask for more weeks ahead, and pace our own callers.
Stage 5: A higher payment limit asked early, and a paced worker
- Load: 31,250 req/sec
- First to saturate: Nothing at this load
- Estimated cost: $33k/mo
Check in: Which two numbers did you change on the cards, and why both?
Show an answer
On the cards: Stripe's limit from 25 to 100 a second, asked for six weeks ahead, and the worker's maximum from 10 tasks to 4.
Both, because a higher limit alone lets 10 tasks send up to about 249 a second. With 4 tasks the worker has at most 100 calls open, about 99.5 a second, under 100.
Off the cards, Stripe also raises the account to 150 a second and open calls to 150, so 109.5 calls and 110 open fit.
The API and the data model
The interviewer asks: Walk me through the API and the data model.
None of these parts breaks under load, so no stage is about them. An interviewer asks about each one, so we answer each in a line or two.
Search: GET /v1/availability with a city, dates and guests returns 200 with hotels, room types, prices and rooms free. Rooms free is a hint.
Hotel page: GET /v1/hotels/{id}/availability returns each room type, its price and rooms free, or 404.
Book: POST /v1/reservations with the hotel, room type, dates, guests, rate plan and card. It returns 202 with a reservation ID, "held" and an expiry time.
It returns 409 when sold out, 400 for bad input, and 422 for a card type we do not accept.
The client sends an idempotency key: a UUID made when the guest opens the booking form. It becomes the reservation ID.
So a second press or a retry hits the same primary key, and the API returns the first result.
Status: GET /v1/reservations/{id} returns held, confirmed, payment_failed, expired or cancelled. A cancel returns 409 past the deadline.
Partners call GET /v1/partners/availability with an API key, and get 429 past their quota.
Data: room_inventory has one row per hotel, room type and night, with total, reserved and price. That triple is its primary key.
A CHECK keeps reserved at most floor(total x 1.10). 8,400 hotels x 4 room types x 730 nights = 24.5 million rows, about 1.5 GB.
Why a row per room type: a guest books a type. Rows per room would be 40 times as many, and a booking would have to pick a room.
The reservations table has one row per booking, keyed by its ID. 492,188 a day x 365 x about 1 KB = 180 GB a year.
An index on status and expiry serves the hold sweeper. An index on guest serves "my trips".
A hold expires after 15 minutes. That covers a payment retry or a short queue, and returns an abandoned room soon.
A sweeper runs every minute. It marks expired holds and subtracts 1 from each night's reserved count.
If Stripe is down, we extend holds 15 minutes at a time, up to an hour, then release them.
Overbooking is one number in the condition and the CHECK. The 110% itself is a revenue decision.
The trade-off:
Each choice has a price. A row per room type cannot assign a room number, so the hotel does that at check-in.
Check in: A guest presses Book twice. Say what stops a second booking, and what the second press gets back.
Show an answer
The idempotency key is the reservation's primary key. The second INSERT hits it, and the API returns the first result: the same held reservation.
Say this in 45 minutes
The interviewer asks: What fails in this design, and what does a guest see?
A design that works on a good day is half an answer. The interviewer asks what fails, what a guest sees, and how stale the data can get.
Duplicates: the queue can deliver a job twice. The worker confirms only where the status is still held, so a second copy changes 0 rows.
A Stripe call that timed out is retried with the reservation ID as its idempotency key. Stripe keeps keys at least 24 hours.
A crash between commit and enqueue leaves a hold with no payment job. It expires in 15 minutes, or an outbox table written in the same transaction closes the gap.
A reader fails: with the cache, the other takes all the search work at 62%. The writer fails: Aurora promotes a reader, and the client retries with the same key.
The cache primary fails: its replica takes over. Until it does, every search reads the database: 24,985 against 4,000.
Finding hotels: 8,400 hotels x about 2 KB = 17 MB, so each API task holds the hotel list in memory. OpenSearch with geo filters suits map or free-text search.
Price changes write through too. One hotel's year of prices is 365 keys, sent in one pipelined call.
When all 110% arrive, the hotel walks a guest: it pays for a comparable room nearby. The system flags an overbooked night days ahead.
Consistency: bookings are strong, one transaction on the writer. Search is at most 5 minutes stale, and readers usually lag well under 100 ms.
Why not DynamoDB? It works, with a conditional update per night in a transaction. Aurora gives the multi-row transaction, the CHECK and SQL search in one place.
Sharding: not yet. The writer runs at 6.4% in the rush. If it ever runs out, shard by hotel, so a booking never crosses shards.
A flash sale bigger than the system needs a waiting room: admit guests at the rate the booking path and Stripe can take.
Minutes 0 to 5: requirements. Search, book, cancel and partners. Ask the size of the group and the overbooking rule.
Minutes 5 to 10: the numbers. 1.25 million rooms, 12.5 bookings a second, about 200 searches each, 2,500 a second.
Minutes 10 to 15: the API with 202 and an idempotency key. One inventory row per room type and night.
Minutes 15 to 22: searches on readers, one short booking transaction on the writer, and payment behind a queue.
Minutes 22 to 28: double booking. The three answers with their numbers, and why the conditional update wins.
Minutes 28 to 34: metasearch. The readers run out, so a cache with write-through, and the database still decides.
Minutes 34 to 40: a convention. Stripe's 25 a second is the wall: ask early, pace, and hold for 15 minutes.
Minutes 40 to 45: holds, duplicates, failures, and why we do not shard yet.
The trade-off:
Each answer has a price. A third reader costs $973 a month for a failure that may never come. A paced worker makes some guests wait for "confirmed".
Check in: Set a timer for 45 minutes. Give the whole answer out loud, and check it against the pacing plan.
Say it out loud
- How do you get to 2,500 requests per second?
- 1.25 million rooms x 63% / 2 nights / 0.8 = 492,188 bookings a day. / 86,400 x 2.2 = 12.5 a second. Round to 2,500: 2,485 searches, about 200 a booking.
- Why can bookings not grow ten times?
- Rooms cap them: at most 1.56 million a day, about 40 a second at the daily peak. It caps a day, not a burst. Searches have no cap.
- How do you size the readers?
- A search reads 160 rows, about 4 ms of CPU, so an 8-vCPU reader serves 2,000 a second. 2,485 / 1,400 = 1.8, so 2 readers.
- Why is the writer nearly idle?
- Writes are 27.5 a second, 1.4% of it. It is as big as a reader because a reader takes over if it fails.
- Why is payment behind a queue?
- A card check takes about a second. The booking holds the rooms, answers 202, and a worker calls Stripe. A slow bank never slows a search.
- What are Stripe's default limits?
- 100 calls a second per account, 25 a second per endpoint, and 50 open at once. For one call per booking, the 25 binds first.
- How do you stop two guests booking the last room?
- One conditional UPDATE per night with the limit in the WHERE clause, backed by a CHECK. The loser waits one short commit, then gets 409. No retries.
- How fast can one inventory row take bookings?
- About 200 a second with a conditional update (5 ms), 170 with SELECT FOR UPDATE (6 ms). With the payment call inside the lock, about 1.
- Why is a stale availability cache safe?
- The booking re-checks the row on the writer. A stale "free" costs a 409 and other rooms, never a double booking.
- A convention opens its room block. What breaks first?
- Stripe's 25 calls a second per endpoint, at 62.5 offered. Our writer is at 6.4% and the readers at 39%.
- How do you get through the rush?
- Six weeks ahead, ask Stripe for 100 a second on the endpoint, 150 on the account and 150 open. Cap the worker at 4 tasks, 99.5 a second.
- A guest presses Book twice. What happens?
- The idempotency key is the reservation's primary key. The second insert hits it, and the API returns the first result.
Check yourself
Two readers each serve 2,000 searches a second. Searches are 99.4% of the load.
Near what load do the readers saturate?
- About 4,024 requests a second
- About 2,000 requests a second
- About 5,000 requests a second
At 25,000 requests a second, searches need 24,985 reads against 4,000. The writer runs at 1.4%.
Does sending searches to the writer fix it?
- No. It adds about 1,973 reads a second against a gap of 20,985
- Yes. The writer is nearly idle
One room remains. Two guests press Book at the same moment. Each booking reads the count, checks it, then writes.
Which change stops a double booking with no retries?
- One UPDATE per night that adds 1 only if the limit allows it
- A version number checked on write
- Read the count a second time before writing
A row gets 200 booking attempts a second. With optimistic concurrency, the window is 5 ms. The share that retries is 1 - e^(-0.005 x attempts).
About what share of attempts must retry?
- About 63%
- About 2.5%
A booking holds its row lock across the 1-second payment call. 5 bookings a second arrive for one row.
After a minute, about how many bookings wait on that row?
- About 240
- None, because 5 a second is low
- About 5
The cache says a room is free. It sold a second ago, and the cache write after that booking was lost.
What happens when the guest books it?
- The conditional update fails, and the guest gets 409 with other rooms
- The room is sold twice
In a convention rush, bookings jump to 62.5 a second. The writer runs at 6.4%, the readers at 39%. Stripe allows 25 calls a second per endpoint.
What breaks first?
- Stripe's per-endpoint limit
- The database writer
- The main hotel's inventory row
The worker sends exactly 25 calls a second. For 10 minutes, 62.5 bookings a second arrive. A hold lasts 15 minutes.
How long does a booking made 5 minutes into the rush wait for "confirmed"?
- About 7.5 minutes
- Under 5 seconds, because the worker is paced
- About 15 minutes, so its hold expires
We ask Stripe for 100 calls a second. Each call takes 1 second, and checkout charges keep 10 calls open.
Why also ask for 150 calls open at once?
- At 62.5 a second, 72.5 calls are open, more than the default 50
- We do not need to. The rate limit is the only one
The paced worker sends 99.5 calls a second. Payment jobs are 0.2% of the load.
At what load does the worker saturate?
- About 49,750 requests a second
- 12,500 requests a second
- 100,000 requests a second
Your turn
On Stage 1, press 4,200 and choose "Open in SysPad", then press Simulate. The result names the Inventory database.
This wall is something you buy. Add one reader: raise "Read replicas" on the Inventory database card by one, and press Simulate again.
Then say which part is now the busiest, and at what load it saturates.
Open the last stage in the editor and change it. Or start from an empty canvas and build the design from memory.
Lessons for this problem
- Modelling users and traffic entry points
- How Application Load Balancer works
- How ECS Fargate works
- How Amazon Aurora works
- How SQS works
- How Stripe works
- How Amazon ElastiCache works
The finished architecture
A complete design of this problem in the example gallery. It may size some parts differently.
Previous: Design a notification system · Next: Design a payment system