Design Process Steps
Follow a repeatable framework from high-level design to deep dives within the time limit.
SPACED REPETITION Β· 15 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 15The boxes come last
It is minute 10. You have agreed the requirements and worked out the numbers that matter (that is Requirements Gathering). The interviewer says, "Great, let's see the design." This lesson is about the next thirty minutes: how to turn requirements into a design you can defend, one decision at a time.
Here is the prompt for the whole lesson: design a ticketing site for concerts and sports.
The first design that comes to mind is a tier of app servers and one PostgreSQL database. To hold a seat, the app reads the seat's status and, if it is free, marks it as held. On an ordinary day that design is fine. Assume 5 million daily users making 30 requests each. That is 150 million reads a day: about 1,700 per second on average and about 5,200 at a 3Γ peak. Orders run at 400,000 a day, 14 per second at peak. One database handles that without noticing.
Then a stadium show goes on sale at 10:00. It has 60,000 seats, and 1,000,000 fans are sitting on the event page. Each fan's page refreshes seat availability every 5 seconds (a compact 7.5 KB bitmap, one bit per seat; the API section shows why it isn't full JSON), and each fan clicks "hold" about every 10 seconds until they get something. The capacities below are rules of thumb for this exercise, not measurements; always say that yours are assumptions.
| At 10:00 | Load | One node handles (assumed) | Over by |
|---|---|---|---|
| Availability reads, all for one event | 200,000/s, 1.5 GB/s | one Redis node: 100,000 small GETs/s, about 1.25 GB/s of network (10 Gbit/s) | 2Γ in requests, 1.2Γ in bytes |
| Hold attempts, all on one event's rows | 100,000/s | one Postgres primary: 5,000 small writes/s | 20Γ |
Predict first: which part breaks first? Would adding app servers help?
Check your answer
Correctness breaks first, and it breaks at two users, not a million. "Read the status, then update it" lets two fans both read available and both be told they hold the same seat. Next comes the write path: 100,000 hold attempts per second against a primary that manages about 5,000, so 20Γ over. Then availability: 200,000 reads per second of a single key, twice the requests one Redis node serves and more bytes than its network card carries.
Adding app servers fixes none of this. The app tier is stateless, and the pressure does not land there. A message queue in front of the database does not fix it either. Fans are waiting for an answer, and a queue that grows by 95,000 requests every second is just a slower way to say no.
The fix is not one clever box. It is a sequence of small decisions, and each one is forced by a requirement. This lesson walks through them inside the course's interview framework, Scope β Sketch β Deep dive β Wrap-up, defined in Interview Framework & Strategy. Scope (Requirements Gathering) is done. This lesson is the Sketch and the Deep dive in depth, plus the 10Γ question from the Wrap-up:
| Phase (45-minute slot) | What you produce | Sections below |
|---|---|---|
| 2. Sketch (minutes 10β20) | API, data model, and a diagram with one write and one read traced end to end | The API Β· The data model Β· The diagram |
| 3. Deep dive (minutes 20β35) | The riskiest part designed properly, with its failure modes: one topic in 45 minutes, two in 60 | Picking the topic Β· Bottlenecks and failures |
| 4. Wrap-up (minutes 35β40) | Summary, top risks, what changes at 10Γ | What changes at 10Γ |
Two checkpoints matter here: by minute 20 the skeleton works end to end, and by minute 35 at least one deep dive is finished. If you fall behind, compress; don't skip a phase.
Every decision in those phases starts from a pressure in the requirements. Learn to spot the pressure and the move follows:
| Pressure in the requirements | Move | Where |
|---|---|---|
| An invariant: "never sell a seat twice" | Let the database check and write in one statement | Sketch: data model |
| Retries over flaky networks, with money involved | An idempotency key on the call | Sketch: API |
| Read-heavy, a few seconds of staleness allowed | A cache, while the write path stays the authority | Sketch: diagram |
| One key read by everyone | In-process copies, or the CDN | Deep dive |
| A burst far above write capacity | Admit users at the rate you can serve | Deep dive |
| One row written by everyone | Split the row into buckets | Deep dive (plot twist) |
| Users worldwide, one invariant | One home region per item | Wrap-up |
The spec for this lesson, as agreed in Scope:
- Functional: browse and search events; see an event's seat map; hold chosen seats for 10 minutes; pay and receive tickets; see my orders. Parked: resale, dynamic pricing, recommendations.
- Non-functional: a seat is never sold twice. A confirmed purchase is never lost. The seat map may be a few seconds stale. Big shows sell out in minutes.
- Numbers: the ordinary day and the on-sale above, plus storage: about 160 GB of new data a year (worked out below).
Sketch: the API is the contract
Write one call per functional requirement. The syntax matters less than what each line decides:
GET /v1/events?city=oslo&from=2026-10-01&cursor=... 200 {events, nextCursor}
GET /v1/events/{eventId}/layout 200 {sections, seats: [{seatId, section, x, y, price}]}
GET /v1/events/{eventId}/availability 200 {asOf, bitmap} one bit per seat
POST /v1/events/{eventId}/holds {seatIds} 201 {holdId, expiresAt} 409 {unavailable: [seatIds]}
POST /v1/holds/{holdId}/purchase {paymentToken} 201 {orderId, tickets} 409 hold lost
header Idempotency-Key: <client-generated UUID>
GET /v1/me/orders?cursor=... 200 {orders, nextCursor}
Six calls fit on one screen. Here is what they commit you to:
- Who is calling comes from the auth token. No request body carries a
userId, and/me/ordersreads the user from the token. A user ID in the body invites anyone to read someone else's orders. - Static and live data are separate calls. The layout (seat positions, sections, prices) is the same for every fan and almost never changes, so the CDN serves it once per fan. Availability changes every second, so it is one bit per seat: 60,000 seats make a 7.5 KB bitmap. A full JSON map at about 80 bytes a seat would be about 4.75 MB on every refresh. Payload size is an API decision, and it shows up in the load pass later.
- Availability says how old it is.
asOfputs "may be a few seconds stale" into the contract, so nobody later "fixes" it by making it strongly consistent. - A hold is a resource with an expiry. The 10-minute rule is visible to the client, which can show a countdown.
- A 409 names the unavailable seats, so the client can redraw the map without another request. That matters when the on-sale crowd is clicking at once.
- Lists use a cursor, so no response grows without bound. Networking Basics covers methods, status codes and pagination in depth.
Now mark the hot path. During an on-sale almost all traffic is GET availability and POST holds. GET events is ordinary browsing. purchase is rarer, but it moves money. These differences decide where the cache goes and where you spend your deep-dive time.
Predict: mobile networks lose responses. Which of the six calls is dangerous to retry blindly, and what goes wrong?
Check your answer
Purchase. The first request may have charged the card before its response was lost, so a blind retry charges again. The fix is an Idempotency-Key header. The client generates one UUID per purchase attempt and sends the same key on every retry. The server stores the first result under that key and returns it for repeats. Payment APIs work this way; Stripe's documentation describes saving the first status code and body per key and pruning keys after at least 24 hours.
The less obvious one is POST holds. If the first hold succeeded and its response was lost, the retry finds the seats held (by you!) and gets a 409. The fan is locked out of their own seats for 10 minutes. Fix it the same way: give holds an idempotency key too, or return the existing hold when a user asks again for seats they already hold (the data model needs to record who owns each hold for that). The GETs are safe to retry.
REST or RPC? For browsers and mobile apps, JSON over HTTP in REST style is the usual default. Between internal services, gRPC with Protocol Buffers is common because it gives typed contracts and generated clients. Either is fine in an interview. The grade comes from the content: inputs, outputs, errors, and who may call what.
The contract anchors the scope. When the interviewer mentions resale, you can say: "That's a new call, POST /v1/tickets/{id}/listings. Should I add it, or keep resale parked?" A written contract makes scope creep visible.
| API trap | Why it hurts |
|---|---|
A userId in the request body |
The server trusts the client about who it is |
| A list with no pagination | The first big result set becomes a huge, slow response |
| Slow work as one synchronous call | It ties up connections, and any timeout turns a success into a retry |
| Fifteen endpoints | You spend the Sketch on calls nobody will draw |
| One call per seat for the seat map | A chatty API: 60,000 requests to draw one screen |
| Static and live data in one response | Every refresh re-sends megabytes that never change |
Say it: "Six calls cover the core flows. The layout is static and goes through the CDN; during an on-sale, the hot calls are availability and holds. Purchase moves money, so it takes an idempotency key. The user always comes from the token."
Likely follow-up: "What does the client do on a 409?" It redraws the map using the seats named in the response and lets the fan pick again.
Your turn: the product adds "transfer a ticket to a friend". Write the call or calls, and say what the server must check.
Check your answer
POST /v1/tickets/{ticketId}/transfers with {recipientEmail} returns 201 {transferId, status: "pending"}. The recipient then calls POST /v1/transfers/{transferId}/accept.
The server takes the sender from the token and checks that the sender owns the ticket (403 if not). It also refuses a second transfer while one is pending. That is another "check and write" invariant, which the data model section teaches you to enforce in one statement. The create call takes an idempotency key as well: without one, a retry whose first response was lost hits the "one pending transfer" rule and fails, the same lock-out as holds.
Sketch: write the queries before the tables
The data model is where the invariant lives. Don't start from nouns. Start from the query each call makes, then choose keys and indexes that serve those queries.
events (event_id PK, venue_id, name, city, starts_at, on_sale_at)
seats (event_id, seat_id, section, price_cents, status, hold_id, hold_expires_at, order_id,
PRIMARY KEY (event_id, seat_id), INDEX (event_id, hold_id))
holds (hold_id PK, event_id, user_id, expires_at)
orders (order_id PK, user_id, event_id, hold_id, status, amount_cents, payment_ref,
idempotency_key, created_at, UNIQUE (user_id, idempotency_key))
tickets (ticket_id PK, order_id, event_id, seat_id)
| Call | Query it runs | Served by |
|---|---|---|
GET events |
events in a city in a date range, ordered by date | index on (city, starts_at); free-text search goes to a search index |
GET layout |
seat positions and prices of one event | primary key prefix event_id; built once, served by the CDN |
GET availability |
the status of every seat of one event, packed into a bitmap | primary key prefix event_id; cached |
POST holds |
insert the hold with its owner; conditionally mark the requested seats as held | holds primary key; seats by (event_id, seat_id) |
POST purchase |
check the caller owns the hold; insert the order; mark the hold's seats sold | holds primary key; unique (user_id, idempotency_key); seats by (event_id, hold_id) |
GET me/orders |
one user's orders, newest first | index on (user_id, created_at) |
Notice that both writes that must be atomic, the hold and the purchase, touch the rows of one event. Remember that; it decides the partition key when you scale out.
Where "read, then write" goes wrong
Here is the naive hold, traced from a real run against PostgreSQL 17. Ana and Ben both want seat 5:
| Moment | Ana's request | Ben's request | Seat 5 in the database |
|---|---|---|---|
| 1 | SELECT status returns available |
available | |
| 2 | SELECT status returns available |
available | |
| 3 | UPDATE ... hold_id = 'ana': 1 row |
held by Ana | |
| 4 | UPDATE ... hold_id = 'ben': 1 row |
held by Ben |
Both updates report "1 row", so both fans are told the seat is theirs. Ben's write silently replaced Ana's. Nothing about this needs a million users; the on-sale only makes it certain.
Wrapping both statements in one transaction does not help at PostgreSQL's default isolation level, Read Committed: both SELECTs still see available. You could add SELECT ... FOR UPDATE or use a stricter isolation level, but there is a simpler move.
The move: one statement that checks and writes
-- one transaction; $4 is the user from the auth token
INSERT INTO holds (hold_id, event_id, user_id, expires_at)
VALUES ($1, $2, $4, now() + interval '10 minutes');
UPDATE seats
SET status = 'held', hold_id = $1, hold_expires_at = now() + interval '10 minutes'
WHERE event_id = $2
AND seat_id = ANY($3)
AND (status = 'available' OR (status = 'held' AND hold_expires_at < now()))
RETURNING seat_id;
-- fewer rows returned than seats requested: ROLLBACK and answer 409
The holds row records who owns the hold, so the purchase can refuse a caller who doesn't own {holdId}, and a retried hold can be answered with the fan's existing hold. The expiry is copied onto each seat row so the conditional update can check it without a join.
Why it works. In Read Committed mode, when an UPDATE reaches a row that another open transaction has already changed, it waits for that transaction to finish. If the other transaction commits, the database re-checks the WHERE clause against the new version of the row (PostgreSQL docs, 13.2.1). The check and the write cannot be separated. Ben's update finds status = 'held', matches nothing, and returns zero rows. When I ran 50 concurrent holds on one seat, exactly one won.
Three details make it complete:
- All or nothing. A fan who asks for seats 8 and 9, when seat 8 was just taken, gets back only seat 9. The code sees 1 row where it asked for 2, rolls back, and seat 9 is free again.
- Expiry needs no timer. An expired hold counts as free inside the same
WHEREclause, so correctness does not depend on a background job. A sweeper can tidy statuses for display, but nothing breaks if it runs late. - The seat map may lie; the hold may not. The UPDATE is the authority. A fan who clicks a seat shown as free, which someone took a second ago, gets a 409, never a double sale.
π§ Reads can be stale when writes re-check. Once the write path enforces the invariant, the read path is only advice. That is what makes it safe to put a cache in front of the seat map. It is the most reused idea in this lesson.
Is the data even big?
Scope gave you the storage number; now use it to pick the store. It came from these assumptions. 200,000 events a year with 2,000 seats each is 400 million seat rows; at about 120 bytes each that is about 48 GB. Orders, 400,000 a day at 500 bytes, add about 73 GB. Tickets, 2.5 per order, come to 365 million a year (about 91% of the seats); at 100 bytes each they add about 37 GB. That is about 160 GB a year, or about 470 GB with three copies. One PostgreSQL primary holds years of that. Size is not the pressure; contention is.
So choose the store for the write you need: a multi-row, all-or-nothing conditional update plus a unique constraint. A relational database does both out of the box. A key-value store with conditional writes and transactions, such as DynamoDB, can enforce the same rule if you name the feature you would use. What you must not choose for seats is a store that only offers last-writer-wins. Databases & Storage covers the store choice and isolation levels in depth.
Your turn: the venue adds a general-admission floor: 20,000 standing tickets with no seat numbers. Change the data model and write the hold. What does "expiry needs no timer" turn into?
Check your answer
Add a counter per section, sections (event_id, section_id, capacity, remaining), and give holds two more columns, section_id and quantity. The hold is again one conditional statement:
UPDATE sections SET remaining = remaining - $1
WHERE event_id = $2 AND section_id = $3 AND remaining >= $1;
-- 0 rows: not enough tickets left, answer 409
When 80 buyers asked for 2 tickets each against 100 remaining, exactly 50 succeeded and remaining ended at 0.
Expiry changes. A seat row carried its own expiry, but a counter doesn't know which holds lapsed. So now you do need a job that returns expired quantities to remaining. It must mark each hold as returned in the same transaction, so a ticket is never given back twice. Keep this counter in mind: it comes back as a problem in the deep dive.
Sketch: the diagram, with one write and one read traced
Draw the fewest boxes that make every API call work. Then trace calls through them: at least one write and one read, end to end. Every box must earn its place from a requirement or a number.
client (browser, app)
| |
static files, | | /v1 API calls
event pages, v v
seat layouts [ CDN ] [ load balancer ]
|
v
[ API servers ] ---------> [ payment provider ]
(stateless) purchase only
|
+-----------------+------+----------+-----------------+
| | | |
| get and fill: | cache misses, | holds, | search
| availability, | my orders | purchases | queries
| events | | |
v v v v
[ Redis cache ] [ read replica ] [ Postgres ] [ search index ]
^ [ primary ] ^
| | | |
+-------------+ +--------------+
replication change feed
| Box | Why it is there |
|---|---|
| CDN | Static files, event pages and seat layouts are the same for every fan |
| Load balancer, stateless API servers | Any server serves any request; add servers for throughput; health checks remove dead ones (Core Building Blocks) |
| Redis cache | Availability bitmaps and event details: read-heavy, and a few seconds stale is allowed. Cache-aside: the API servers read it and fill it on a miss |
| Postgres primary | Holds and purchases: the invariant needs one authority |
| Read replica | Cache misses and "my orders" stay off the primary |
| Search index | Free-text search, fed from the primary's change feed; seconds of lag are fine |
| Payment provider | External; only the purchase call uses it |
There is no message queue. Nothing in the requirements needs asynchronous work at 14 orders a second. One may appear in the deep dive if a number demands it. "Kafka, because it's big" is a box you would have to defend, and you can't.
Now trace the hot read and the hot write:
| Hop | GET /v1/events/{id}/availability (read) |
POST /v1/events/{id}/holds (write) |
|---|---|---|
| 1 | The load balancer picks any API server | The load balancer picks any API server |
| 2 | The API server looks up key avail:{id} in Redis |
Read the user from the token |
| 3 | Hit: return the bitmap with its asOf time |
Insert the hold and run the conditional UPDATE on the primary |
| 4 | Miss: the API server reads the statuses from the replica, packs the bitmap, writes it to Redis with a 2 s TTL, returns it | All rows back: COMMIT, 201. Fewer: ROLLBACK, 409 with the unavailable seats |
If a call has no path through your diagram, or a box has no call through it, the diagram is wrong. Checking that takes thirty seconds and catches most mistakes.
Structure before products. Say "a relational database that supports conditional updates" and "an in-memory cache" first. Name products when the interviewer asks, or when a product feature decides the question. For example: "PostgreSQL, because I'm relying on how Read Committed re-checks a row that changed."
Then check in: "Before I go deeper: does this shape work for you? I'd like to spend the deep dive on holds during an on-sale. Is there something you'd rather see?" The classic mistake is to start on index tuning or cache eviction before the shape is agreed. Details built on a shape the interviewer rejects get thrown away.
Your turn: the payment provider sometimes takes 20 seconds to answer. Trace POST purchase through the diagram. What goes wrong, and what do you change in the API?
Check your answer
The request holds a connection and an API worker for 20 seconds. Any client, proxy or load-balancer timeout shorter than that turns a successful payment into an error the fan retries. The idempotency key stops a double charge, but the fan still sees a failure.
Make purchase asynchronous. The call records a pending order and returns 202 Accepted {orderId}. The client then polls GET /v1/orders/{orderId}, or gets a push, until the order is paid or failed. You now also need the hold to outlive the payment, which the purchase deep dive handles.
Deep dive: pick the riskiest part
In a 45-minute round the deep dive runs from minute 20 to 35. That is fifteen minutes: enough for one topic done properly, with its failure modes. A 60-minute round buys a second topic, not a longer version of the first. You will have more candidates than time, so rank them with three questions: which stated requirement is at risk, how far the numbers exceed one node, and how specific the problem is to this prompt.
| Candidate | Requirement at risk | Numbers | Specific to ticketing? | Verdict |
|---|---|---|---|---|
| Holds during an on-sale | never sell twice; sells out in minutes | 20Γ one primary | yes | The topic |
| Purchase and payment | a confirmed purchase is never lost; charged once | 14 orders/s at an ordinary-day peak, about 110/s during an on-sale (derived below); external and slow | yes | Second topic in a 60-minute round |
| Availability reads | a few seconds stale is allowed | 2Γ one Redis node's requests, on one key | partly | Part of the holds topic |
| Search | nothing beyond ordinary latency | part of 5,200 reads/s at peak | no, generic | One sentence |
| Accounts and login | none stated | small | no | Skip |
The winner has a pattern: a hard requirement meets the component under the most load. Search is real work, but it is the same in every product, and nothing in this spec pushes on it.
The interviewer's steer beats your ranking. If they say "let's talk about search", go there, because their rubric may need it. Interview Framework & Strategy covers steering and what to do when you run out of time.
Say it: "The riskiest part is holds under an on-sale burst: that's where 'never sell twice' meets 20 times one database's write rate. Search is standard, an index fed from the database's change stream, so I'll leave it at that unless you want more. If we have time, the purchase flow is next."
Your turn: same prompt, but the interviewer says, "Assume small theatres, 300 seats each, and demand is never bursty." In a 45-minute round, which topic do you take now?
Check your answer
Purchase and payment. Without bursts, holds need nothing beyond the conditional update you already showed in the Sketch; mention it in one sentence. The riskiest remaining part is the one that moves money and must never lose a confirmed purchase: the pending order, the idempotent charge, and what happens when the provider is slow or your server crashes mid-payment. The requirements changed, so the topic changed.
Deep dive: find the bottleneck, remove it, name the cost
The method has two passes:
- Load pass. For each box on the hot path, compare the peak load with what one node handles, in requests and in bytes.
- Failure pass. For each box, ask: "What happens when this dies, goes slow or goes cold?"
The load pass is arithmetic you can do aloud. Here it is for the on-sale:
# The on-sale minute for one stadium show. Assumptions, not measurements.
fans, refresh_s, hold_every_s = 1_000_000, 5, 10
bitmap_bytes = 60_000 // 8 # availability: one bit per seat
pg_writes_per_s = 5_000 # one Postgres primary, small write transactions
redis_gets_per_s = 100_000 # one Redis node, small GETs
redis_bytes_per_s = 10e9 / 8 # one Redis node's 10 Gbit/s network card
reads = fans / refresh_s
rows = [
("availability requests", reads, redis_gets_per_s, "/s"),
("availability bytes", reads * bitmap_bytes / 1e9, redis_bytes_per_s / 1e9, " GB/s"),
("hold attempts", fans / hold_every_s, pg_writes_per_s, "/s"),
]
for part, need, have, unit in rows:
print(f"{part:22} {need:>9,.1f}{unit:5} capacity {have:>9,.2f}{unit:5} -> {need / have:.1f}x")
availability requests 200,000.0/s capacity 100,000.00/s -> 2.0x
availability bytes 1.5 GB/s capacity 1.25 GB/s -> 1.2x
hold attempts 100,000.0/s capacity 5,000.00/s -> 20.0x
The byte row is why the API splits layout from availability. Serve the full JSON seat map instead (about 4.75 MB) and the byte row reads 200,000 Γ 4.75 MB = 950 GB/s, about 760 times one node's network card. Count bytes, not just requests.
Name it before you fix it: "The first bottleneck is the hold path. A hundred thousand attempts a second land on one event's rows in one primary that handles about five thousand. That's 20Γ. And every attempt needs the primary, because that's where the invariant lives."
Move 1: a waiting room
The seats (60,000) are fixed, and the primary's write rate (about 5,000/s) is fixed. The crowd only changes how many people wait, not how fast you can sell. So cap the thing you can't scale, which is concurrent shoppers, and make the thing that does scale, the waiting page, cheap.
fan --> [ waiting room ] -- admission token --> [ API servers ] --> holds
position = your number - "now serving"
"now serving" is one number, cached at the CDN for 1 s
When sales open, each fan gets a signed queue token with an arrival number. The system admits fans into seat selection at a fixed rate, and the hold call rejects anyone without an admission token. Everyone else sees their position. Computing it needs one global number, which the CDN can serve.
How fast should you admit? Run the primary at half its capacity and work backwards:
pg_writes_per_s, hold_every_s = 5_000, 10
hold_budget = 0.5 * pg_writes_per_s # keep the primary at half load
shoppers = hold_budget * hold_every_s # each shopper tries a hold every 10 s
admit_per_s = shoppers / 180 # a shopper is done within about 3 minutes
fans_needed = 60_000 / 2.5 / 0.8 # 2.5 tickets per order, 80% of shoppers buy
print(f"{shoppers:,.0f} shoppers at once, admit {admit_per_s:,.0f}/s, "
f"{fans_needed / admit_per_s / 60:.1f} min to admit {fans_needed:,.0f} fans")
25,000 shoppers at once, admit 139/s, 3.6 min to admit 30,000 fans
About 8,300 fans a minute go through. The show sells out a few minutes after opening, and the remaining 970,000 fans are told so. The availability storm disappears too: only the 25,000 admitted fans see the seat map, so availability gets 5,000 reads a second (about 38 MB/s) instead of 200,000 (1.5 GB/s).
What you give up: fans wait, and you need a fairness rule you can defend (arrival order, or random order among everyone present at opening). The waiting room is now on the critical path, so it must scale itself; that is why it serves a static page, a signed token and one cached number. Bot defence is real work too, and it stays parked unless the interviewer raises it.
Move 2: the hot availability key
Suppose a mid-sized show runs without a waiting room, or the interviewer asks what happens if you remove it. Availability is one key. Redis Cluster maps each key to one of 16,384 hash slots, and one primary node serves each slot (Redis cluster spec). Adding shards does not spread a single key. Replicas can serve reads if you accept stale data, which helps a little. Two moves help more:
- An in-process copy. Each API server keeps the availability bitmap in memory for 1 second. With 100 API servers, Redis sees about 100 reads a second per event (under 1 MB/s), however many fans there are. The fleet still sends 1.5 GB/s to fans, but spread over 100 servers that is 15 MB/s each. A CDN with a 1-second max-age could carry it instead.
- Refresh, don't rebuild on a miss. With cache-aside, the moment the key expires, every concurrent reader misses and goes to the database together. That is a cache stampede. Instead, for events on sale, one refresher job rebuilds the bitmap every second, and readers never trigger a rebuild. (Databases & Storage covers stampedes and their fixes.)
What you give up: staleness adds up. Replica lag, plus the 1-second refresh, plus the 1-second local copy, comes to a few seconds. The requirement allows that, and it is safe because holds re-check. Most fans also want the same few hundred front seats, so many holds will return 409. A "best available" option, where the server picks free seats in a section, cuts those conflicts. It is a product change, so offer it rather than assume it.
The failure pass
Go box by box and say what happens:
- The primary dies mid-sale. With asynchronous replication, the last few commits may be missing on the promoted replica: a fan was charged for an order the new primary has never heard of. "A confirmed purchase is never lost" therefore means synchronous replication to a standby. The commit returns only after the standby has the change. The cost is a round trip to the standby on every commit, and if your only synchronous standby is down, commits stall, so run two. During the seconds (or tens of seconds) the failover takes, holds fail and the waiting room pauses admission.
- A Redis node dies. In-process copies ride out a few seconds, and the refresher fills the replacement node. Without the refresher, every reader would go to the database at once.
- A retry storm. Fans and apps hammer "hold" after 409s and timeouts. Clients retry with exponential backoff and jitter, 409s name the seats that were taken, and the admission cap bounds the total however often people click.
Likely follow-up: "Why not put a queue in front of the database instead of a waiting room?" A queue smooths writes that nobody is waiting on. Here every request has a fan staring at a spinner, and the queue would grow by 95,000 requests a second. A waiting room is admission control before the work starts, with an honest position shown to the user. Queues and backpressure are covered in Messaging & Queues.
Plot twist: the general-admission floor
You are ten minutes into the holds deep dive when the interviewer says: "Oh, and the floor is 20,000 standing tickets." Your answer to the general-admission exercise in the data model section, one counter row per section, is now a hot row. Every GA hold updates that one row, and a row lock is held until commit, so GA holds run strictly one at a time.
Why that caps throughput: assume each GA hold transaction keeps the row locked for about 2 ms. The counter update, the insert of the hold row and the commit are separate round trips from the app server, and the commit waits for the disk and the synchronous standby. Then one row manages at most about 1 Γ· 0.002 s = 500 updates a second, however big the machine. Now suppose 30% of the 25,000 admitted shoppers want the floor. That is 25,000 Γ 0.1 holds a second Γ 0.3 = 750 GA holds a second against a row that manages about 500.
Move: split the counter into 8 bucket rows of 2,500 each. A hold picks a random bucket and tries another if that one is empty. Eight rows give roughly 8 Γ 500 = 4,000 updates a second. What you give up: a hold can miss on an empty bucket while others still have stock, so it retries, and "sold out" now means every bucket is at zero.
How you say this matters as much as the fix. Compare:
- β "Oh no, general admission. Then I think my whole design is wrong."
- β "That changes one decision. Seats were separate rows, so contention spread across 60,000 of them. The floor is one counter, so every GA hold waits on one row lock. I'll split it into 8 bucket rows. Nothing else in the design changes."
The second version names the new fact, the one component it breaks, and the targeted fix, and it keeps the rest of the design. That is graceful backtracking: you revisit an earlier decision out loud, precisely, when new information arrives.
A second topic in a 60-minute round: the purchase flow
With a longer slot, the purchase flow is next. Its risks are money and "never lost", and its failure modes are the whole topic:
- The server crashes after charging the card. Insert the order as
pendingbefore calling the provider, and pass the provider the same idempotency key. A reconciler finds pending orders older than a few minutes, asks the provider what happened, and completes or refunds each one. (Sagas and the outbox pattern are in Advanced Topics & Final Prep.) - The hold expires during payment. The purchase checks that the
holdsrow belongs to the caller, then marks seats sold withWHERE event_id = $2 AND hold_id = $1 AND status = 'held', which the(event_id, hold_id)index serves. If the clock ran out but nobody took the seats, they still match and the sale completes. If someone else took them, zero rows match, so refund and say so. Make that rare: don't start a payment with less than two minutes left on the hold.
Your turn: the payment provider's contract allows only 50 concurrent calls, and each takes 2 seconds. What is the most orders per second you can complete? How long does the sell-out take now, and what happens to the admission rate?
Check your answer
Calls in flight = rate Γ duration (Little's law, from Key Concepts & Terminology). So the most you can complete is 50 Γ· 2 s = 25 orders a second, or 1,500 a minute. The 24,000 orders now take about 16 minutes. Admission must drop to about 25 Γ· 0.8 β 31 fans a second. Otherwise admitted fans pile up waiting for the provider while their holds tick toward expiry. The bottleneck moved from the database to the provider. The fix is a business conversation (a higher limit, or a second provider), and until then the honest answer is "sell-out takes 16 minutes".
Wrap-up: what changes at 10Γ
The wrap-up (minutes 35β40) is a one-sentence summary, your top risks, and what changes at 10Γ. Most interviewers ask some version of "What if traffic grows 10Γ?", and the answer is the loop you ran in the deep dive: change one number, recompute the table, find the first thing that breaks, make the move, name the cost. In five minutes you will cover one or two of the rows below; the full table is here so you can see the method.
| What changes | Recompute | Breaks first | Move | You give up |
|---|---|---|---|---|
| 10Γ more events and browsing | about 52,000 reads/s at peak, spread over many keys (about half of one cache node); about 140 orders/s; 4 billion seat rows and about 1.6 TB of new data a year | nothing at once. The pressure is one primary growing by 1.6 TB a year, plus more on-sales at the same time | add cache and replica capacity as reads grow; partition seats and orders by event_id before storage or concurrent on-sales outgrow one primary |
"my orders" becomes a cross-partition query |
| One show with 10Γ the crowd | 10 million fans; about 33,000 queue tokens/s over 5 minutes | the waiting room, not the database | stateless signed tokens; position from the CDN-cached "now serving" | 99.7% of fans wait and get nothing: a product problem, not a scaling one |
| Fans worldwide | one cross-region round trip on each hold | latency for remote fans | one home region per event; reads served locally | remote fans pay roughly 70β250 ms more per hold; a home-region outage stops that event's sales until failover |
"Nothing breaks yet, and here is the number that will break first" is a strong answer, as long as you show the arithmetic. Three ideas are hiding in that table.
Pick the partition key where the invariant lives. Every hold and every purchase touches exactly one event, so partitioning by event_id keeps each of those transactions on one partition and you never need a cross-partition transaction. The query that suffers is "my orders", which is keyed by user. Serve it from a second table keyed by user_id, filled from the orders' change feed. The cost is a pipeline to run and a few seconds of lag on that page, which is fine because the purchase response already showed the order.
A bigger crowd doesn't change the booking path. The admission rate was set by the primary's write budget and the shopping time, and the seats set how many fans are admitted in total. The number of fans set neither. At 10Γ the crowd, the booking path is identical and only the waiting room grows. Recognising which number drives which box is the whole skill.
One writer per item, even across regions. Letting two regions each accept holds for the same seats needs either cross-region consensus on every hold or tolerance for double sales, and the spec forbids double sales. Giving each event a home region keeps one authority per seat. Remote fans pay one round trip, and browsing stays local.
Your turn: a promoter puts 20 stadium shows on sale at the same minute. What breaks, and what do you change?
Check your answer
Each show needs its own hold budget of about 2,500 writes a second. Twenty shows need about 50,000 a second in total, which no single primary manages. With seats partitioned by event_id, the danger is two hot shows landing on the same partition. Hash placement can put them together. So place hot events deliberately: keep a small directory from event to partition and give each on-sale show its own. Run one waiting room per show so that each queue is fair on its own terms. The cost is placement logic you now operate, and it has to be done before 10:00, not during.
Final round: no label on the prompt
Real prompts don't tell you where the hard part is. Sketch it, pick the riskiest part, and let the numbers point.
Challenge 1: a game-key giveaway
A game studio gives away 10,000 keys at 18:00. About 2,000,000 players will click "claim" within the first 10 seconds. Each player may claim at most one key, and no key may go to two players.
Before peeking:
- What are the API calls?
- What is the one write that protects both rules?
- What breaks first, with numbers?
- What is your move, and what does it cost?
Hint
It is the ticketing problem with two twists. Every key is interchangeable, and the "one per player" rule needs its own guard. Think about which rows the concurrent claimers fight over.
Check your answer
API. POST /v1/giveaways/{id}/claims returns 201 {key} for a new claim, 200 {key} with your existing key if you already claimed (so a retry after a lost response still gets its key, not an error), or 410 once the keys are gone. GET /v1/giveaways/{id}/claims/me shows your key again. The user comes from the token.
Data and the write. Use keys (giveaway_id, key_id, code, user_id) and claims (giveaway_id, user_id, key_id) with primary key (giveaway_id, user_id). In one transaction, insert the claim (the primary key rejects a second claim by the same player, and the handler answers it with that player's existing key) and take one free key:
WITH k AS (SELECT key_id FROM keys
WHERE giveaway_id = $1 AND user_id IS NULL
ORDER BY key_id LIMIT 1 FOR UPDATE SKIP LOCKED)
UPDATE keys SET user_id = $2 FROM k
WHERE keys.giveaway_id = $1 AND keys.key_id = k.key_id
RETURNING keys.code;
SKIP LOCKED lets concurrent claimers take different free rows instead of queueing on the same one. Because the keys are interchangeable, there is no hot row. In a test with 30 keys and 80 concurrent claims from 70 players, 10 of whom clicked twice, 30 distinct keys went to 30 distinct players.
What breaks first. 2,000,000 claims in 10 seconds is 200,000 a second against about 5,000, so 40Γ over, and only 10,000 claims can ever succeed.
Move. Once the keys are gone, answer 410 from an in-memory "sold out" flag without touching the database. Before that, either admit players through a waiting room, or change the product to a lottery: collect entries for ten minutes and draw 10,000 winners offline. The lottery removes the burst entirely. Cost: a waiting room makes people wait; a lottery trades first-come-first-served for random, which is a product decision to raise with the interviewer, not one to make silently.
Challenge 2: export to PDF
Users click "Export to PDF" on reports that take 30β90 seconds to render. At peak there are 2,000 exports an hour. Files are kept for 7 days.
Check your answer
API. Slow work never goes in one synchronous call. POST /v1/exports {reportId, params} returns 202 Accepted {exportId, status: "queued"}. GET /v1/exports/{exportId} returns the status, plus a signed, expiring download URL when the file is ready. Optionally, send an email or webhook when it is done.
Data. exports (export_id, user_id, report_id, params_hash, status, file_key, created_at, expires_at). Use params_hash to return an existing export when someone clicks twice.
Diagram. API β queue β render workers β object storage, with workers updating the export row.
Bottleneck. The worker pool. Arrivals are 2,000 an hour, about 0.56 a second. Little's law gives 0.56 Γ 90 s = about 50 renders in progress in the worst case, so run about 50 workers plus headroom. The queue absorbs bursts in the meantime. At 10Γ, that is about 500 workers: autoscale on queue depth, and let an object-storage lifecycle rule delete files after 7 days. Cost: users wait for a file instead of getting a response, and you operate a queue and a worker fleet (Messaging & Queues).
Cheat sheet: pressure β move β cost
| When the requirements say⦠| Reach for | You give up |
|---|---|---|
| an invariant on a row ("never twice") | one conditional write; check the row count | contention on popular rows |
| retries plus money | an idempotency key stored with the first result | a key table; keys expire |
| slow work (seconds or more) | 202 plus a job resource; a queue and workers | a status endpoint; results arrive later |
| read-heavy, staleness allowed | a cache, with writes as the authority | some requests fail on stale data (409) |
| one key read by everyone | in-process copies; a CDN with a short max-age | seconds of staleness |
| a large response read by everyone | split static data (CDN) from live data (compact) | two calls for the client to merge |
| a crowd rebuilding a cache on a miss | one refresher, or request coalescing | a background job to run |
| a burst far above write capacity | admission control (a waiting room) | users wait; fairness to defend |
| one row written by everyone | split it into buckets | "sold out" becomes a sum; retries on empty buckets |
| too many writes for one primary | partition by the key the invariant lives in | cross-partition queries need their own path |
| users worldwide plus one invariant | one home region per item | remote users pay a cross-region round trip |
| confirmed writes must survive failover | a synchronous standby (run two) | commit latency |
At the end of each phase, say its result in one sentence and check in with the interviewer before moving on. When new information breaks an earlier decision, name the fact, the one decision it changes, and the fix.
Before moving on, take the giveaway or the PDF export and explain it aloud in five minutes, from a blank page, as you would in the interview. Give no more than five API calls. Say the one write that protects the invariant and trace one call through your diagram. Say what breaks first, with the numbers, then give the move, the cost, and what changes at 10Γ. If you can do that without notes, you can do it on a prompt you have never seen.
Next: Realistic Design Examples applies the same framework to complete designs, and URL Shortener (e.g. TinyURL) and Twitter/Instagram News Feed run them on classic prompts. To practise the whole round under a clock, go to Mock Interviews & Communication.