Deep dive 6 · 20 min

Request Flows End to End

Interviewers want to hear the path a request takes. Know the read, write, async, and failure flows and what each layer contributes.

Say it in 30 seconds
  • 2%reads reaching database at 80% CDN, 90% Redis hits
  • 200 msexample p99 budget, with 40 ms headroom
  • 9.6%page loads hitting a slow call of 10 parallel
Show what to say

“I walk five flows. Read goes CDN, then cache, then database. Write validates, then commits the order and an outbox row in one transaction and answers 201. Async answers 202 and a worker finishes. Failure uses timeouts, a breaker and a fallback. Retries carry an idempotency key. Every request gets a latency budget, enforced by timeouts and a propagated deadline.”

Trade-offs

  • Outbox vs publish after commit: the outbox needs a relay and delivers at least once, but a crash between save and publish loses nothing.
  • Sync 201 vs async 202: 202 keeps slow work off the request, but the client must poll, get a webhook or listen over SSE.
  • Retry vs fail fast: retries rescue transient failures, but only for idempotent calls with jitter and a small budget, or they amplify an outage.

Bring it up before they ask

Checking then claiming an idempotency key in two steps lets two concurrent requests both charge; claim it atomically with SET NX.

0 of 0 sections ticked
30-second answer

Know five flows cold: read, write, async, failure and retry. Give every request a budget and enforce it with timeouts.

Why it matters

"Walk me through what happens when…" is the most common follow-up in a design interview.

Read more · 224 words

It tests whether every box on your diagram has a job. Five flows cover almost every system, and each one answers a different question: where does the answer come from, what is saved, what can wait, what breaks, and what if it runs twice.

Pick a flow in the diagram. In Read, try a miss, then a miss.

Every request takes one of four routes, and picking the route is the first design decision. The tree picks it from three questions; the table says what each route answers with. retries get their own section below.

FlowResponds withProtected by
Read200 from CDN, cache or DBCDN, cache, TTLs
Write201 after the commitValidation, the outbox
Async202 + LocationA queue, retries
Failure200 degraded, or a fast 503Timeouts, breaker, fallback

The default answer is the cached read for anything shared, and a transactional write for anything that changes data.

Press Play to watch it run · 2 steps

Pick a flow: a read through the CDN and cache, a write with a transactional outbox, an async job, a failing dependency, and a retried payment.

Five request flows through the same systemPick a flow: a read through the CDN and cache, a write with a transactional outbox, an async job, a failing dependency, and a retried payment.alt [Read]GET /products/42alt [CDN hit]200 from the edge (~20 ms)The origin never sees this request
  • Client
  • CDN
  • Service
  • Cache
  • Database
  • Queue
Text description
  1. Browser → CDN: GET /products/42
  2. CDN replies to Browser: 200 from the edge (~20 ms)
Which flow does this request take?yesyesnonoyesyesnonoAsync jobWrite with anidempotency keyPlain write with outboxWould a double run costmoney or send a secondemail?Does the work takelonger than a second ortwo?Cached readDirect readIs the answer the samefor many users and fineto be a minute old?Does the request changedata?

Does the request change data?

Latency budgets and tracing

A request that has no budget gets as slow as its slowest hop.

Read more · 599 words

Split the user-facing target across the hops, and keep headroom. A 200 ms budget:

Hopp99 budgetEnforced by
TLS + CDN edge30 msKeep-alive, a nearby edge
Load balancer + gateway10 msLocal JWT checks
Service logic20 msNo blocking work
Redis5 msA command timeout
Database50 msIndexes, statement_timeout
Downstream service45 msA per-call timeout
Headroom40 msRetries, GC pauses
Total200 ms

The arithmetic: 30 + 10 + 20 + 5 + 50 + 45 + 40 = 200. Adding p99s is a conservative sum, because all hops are rarely slow at once. That is the point: the headroom pays for the bad day.

Slow calls add up: make 10 parallel calls, each 1% likely to be slow, and about 10% of page loads hit a slow one. The exact figure is 1 - 0.99^10 = 9.6%. So per-hop budgets must be tighter than the page's.

A deadline is a budget that travels with the request, so every hop knows how long it has left. The gateway sets 200 ms, and each hop passes on what remains. does this natively; over HTTP you pass a header of your own. Use it on any chain of three or more services. Without it, a service keeps working 400 ms after the client gave up, and the retry that follows doubles the load. The cost is discipline: every service must read the header and honour it.

Serial or parallel calls. Calls that do not depend on each other should run together: parallel calls cost the slowest one, serial calls cost the sum. Profile (20 ms), prices (30 ms) and stock (45 ms) take 20 + 30 + 45 = 95 ms in a row and 45 ms together. Keep them serial only when one call needs the previous answer, such as loading a cart and then pricing its items. The cost of parallel calls is the tail: with 3 parallel calls the page is as slow as the unluckiest of three.

Each span runs against a 200 ms budget line. Turn off the and , slow the database, or time out a dependency: which hop blows the budget?

Tracing. One id must tie five services' logs together. That is the traceparent header.

It has four parts: a version (00), a 32-character trace id shared by every hop, a 16-character id of the calling span, and flags (01 means sampled). Each service keeps the trace id and swaps in its own span id. OpenTelemetry handles it for HTTP, gRPC and database clients. You put the trace id in every log line and copy the header into queue messages. Play Across calls, then Across a queue.

Tracing every request costs money: at 10,000 req/s with 20 spans each, that is 200,000 spans a second. Keeping 1% (head sampling) cuts it to 2,000 spans/s, but 99% of the rare slow requests are dropped too. Tail-based sampling decides after the trace ends and keeps all the errors and the slowest few percent, which are the ones you need.

Three downstream calls: one after another, or together0 ms19 ms38 ms57 ms76 ms95 msSerialProfileProfile: 0–20 msPricesPrices: 20–50 msStockStock: 50–95 msParallel: profileProfile 20Profile 20: 0–20 msParallel: pricesPrices 30Prices 30: 0–30 msParallel: stockStock 45Stock 45: 0–45 msParallel doneSerial done

Serial calls add up to 20 + 30 + 45 = 95 ms. The same calls in parallel finish with the slowest one, at 45 ms.

Text description
  • Serial: Profile (0–20 ms), Prices (20–50 ms), Stock (50–95 ms)
  • Parallel: profile: Profile 20 (0–20 ms)
  • Parallel: prices: Prices 30 (0–30 ms)
  • Parallel: stock: Stock 45 (0–45 ms)
Interactive

Trace waterfall

One request, every hop, against a 200 ms latency budget. Flip the toggles to see which hop eats the budget.

0 ms50 ms100 ms150 ms200 msDNSTCP + TLSCDN edgemiss└ API service└ Redishit└ Databasenot called (cache hit)└ Recs service└ Queue publishDNS: DNS lookup, 0–12 ms (12 ms)12 msTCP + TLS: TCP + TLS 1.3, 12–52 ms (40 ms)40 msRedis: GET product:42, 76–78 ms (2 ms)2 ms · hitRecs service: GET /recs, 76–96 ms (20 ms)20 msQueue publish: publish view event, 96–99 ms (3 ms), async: not on the critical path3 ms · asyncAPI service: GET /product/42, 71–101 ms (30 ms)30 msCDN edge: CDN miss → origin, 52–118 ms (66 ms)66 ms · miss200 ms budgettotal 118 ms

Total 118 ms of the 200 ms budget (59%): within budget. Biggest hop: TCP + TLS (TCP + TLS 1.3, 40 ms). Redis → DB and the recommendations call run in parallel, so the slower branch sets the pace; the queue publish is fire-and-forget.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Press Play to watch it run · 3 steps

The traceparent header is copied on every call and into queue messages, so one trace id ties all the logs together.

One trace id across services and a queueThe traceparent header is copied on every call and into queue messages, so one trace id ties all the logs together.ididcopyGatewaymakes the idOrdersPaymentsEvent queueEmail worker

Tap any box to see what it is and what it talks to.

  • API gateway
  • Service
  • Queue
  • Worker
Open in playground
Text description
  1. The gateway creates trace 4bf9 and sends traceparent: 00-4bf9...-01 with the call.
  2. Orders opens its own span, keeps trace id 4bf9, and sends its span id as the new parent.
  3. Every log line in both services carries 4bf9. One search shows the whole request.

Read path

A read should stop at the first layer that has the answer.

Read more · 422 words

, , database: each layer soaks up traffic. At 80% CDN hits and 90% hits, only 0.2 × 0.1 = 2% of reads reach the database. See the caching lesson. Play the three scenarios: CDN hit, Redis hit and Full miss.

LayerWhat happensUse it forCost
CDN hitAnswers from an edge near the user in about 20 ms; the origin never sees the request. The cheapest read there isAnything identical for every user and fine to be 60 s old: product pages, images, public API responses. Never per-user dataStaleness: after an edit, the edge keeps the old copy until max-age ends or you purge it
Redis hitThe request reached the origin, but the database stays idle: about 20 + 30 + 1 = 51 msShared data that many users read and few change, with cache-aside as the defaultA copy that can be stale until the TTL ends or a write deletes the key
Database readA full miss pays everything: about 20 + 30 + 1 + 20 + 1 = 72 ms, and it refills Redis for the next readerThe restLatency, and load on Postgres

Weigh the layers by how often each answers. At 80% CDN hits and 90% Redis hits the average read is 0.8 × 20 + 0.2 × 0.9 × 51 + 0.2 × 0.1 × 72 = 16 + 9.2 + 1.4 = about 27 ms, and only 2% of reads touch the database. At 10,000 reads/s that is 200 reads/s for Postgres, not 10,000.

What goes wrong: at 9:00 on Monday the expires and thousands of requests miss at once. That is a ; the fix (one rebuilder, the rest wait) is in the caching lesson. Also set a timeout on both Redis (5 ms) and the database (50 ms), so a slow layer becomes a fast fallback, not a stuck request.

Press Play to watch it run · 2 steps

A read stops at the first layer that has the answer: CDN, then Redis, then the database.

The read path, layer by layerA read stops at the first layer that has the answer: CDN, then Redis, then the database.GETmisscheckmissBrowserCDN edge~20 msAPI+30 ms to reachRedis~1 msPostgres~20 ms

Tap any box to see what it is and what it talks to.

  • Client
  • CDN
  • Service
  • Cache
  • Database
Open in playground
Text description
  1. GET /products/42 reaches the nearest edge.
  2. Edge hit: 200 in about 20 ms. The origin never hears of it.

Write and async paths

A write has one job: make the truth durable once, then let everything else catch up.

Read more · 923 words

Save the truth in one , answer, and let the rest catch up:

  1. Validate input first.
  2. In one transaction, insert the order and an row for the event.
  3. Respond 201 Created.
  4. A relay publishes order.created from the outbox.
  5. update , search and email, ignoring duplicates.

Play Outbox commit, then Save, then publish and Relay crash.

  • Save, then publish is the obvious design: write the row, then publish the event from the app. It has a gap: a crash between the two steps leaves an order with no confirmation email, ever. The gap is silent, the worst kind: no error, no retry, no alert. Use it only for events you can afford to lose, such as a page-view counter.
  • The outbox makes the event part of the commit. The order and the event are two rows in one transaction, so both exist or neither does. The request answers 201 in about 30 ms and a relay does the publishing. Use it for orders, payments and anything another service must hear about. It costs a relay process, an outbox table to clean up, and a delay of a few hundred milliseconds before consumers see the event. The relay in the code below polls every 200 ms when idle, so the delay is at most 200 ms plus the publish.
  • Relay crashes and duplicates. The relay delivers at least once, never exactly once. If it publishes event 812 and crashes before marking the row sent, it publishes 812 again after a restart. That is the right trade: a duplicate is easy to handle, a lost event is not. Every consumer keeps the ids it has handled and skips repeats. At 500 orders/s with batches of 100, the relay needs 5 batches a second; if a batch takes 50 ms, one relay drains 2,000 events/s, so there is plenty of room.

Async. Slow work (reports, video) leaves the request: enqueue a job and answer 202 Accepted with a status URL. The client finds out by , a , or . Workers retry with . Repeat failures land in a for a human. A job can run twice, so make it . Play Submit and poll: the client is released in a few milliseconds, though the work takes about 40 s.

Use it when the work takes longer than a second or two, when it can fail and be retried, or when a burst should wait in a queue instead of overloading the database. Skip it when the user needs the result to continue and the work takes 100 ms: an extra queue just adds .

  • Retry with backoff. A failed job waits before trying again, with a growing delay. With attempts: 3 and a 5 s exponential backoff, attempt 2 starts 5 s after the first failure and attempt 3 starts 10 s after the second. Retrying at once would hammer a dependency that just said 503. The cost is latency on the bad day: a job that fails twice finishes at least 15 s late. Retry only transient errors (timeouts, 503). A 400 will fail the same way every time.
  • Poison jobs. A poison job fails every time, for instance because its input is malformed. Without a limit, it retries forever and fills the queue with noise. After 3 attempts it parks in the dead letter queue. Alert when that queue is not empty. Someone fixes the input or the code and replays the job, so the worker must be idempotent.

Pick the way the client learns the result by who is waiting. Answer the questions in the tree.

Poll by default: the status endpoint is a simple read. The numbers show the cost: 1,000 users waiting on 40 s jobs and polling every 2 s send 500 requests a second and 20 per job. A Retry-After of 5 s cuts that to 200 a second and 8 per job.

Press Play to watch it run · 6 steps

The order and its event commit together; a relay publishes the event afterwards.

The write path and its outboxThe order and its event commit together; a relay publishes the event afterwards.POSTsavepublishrelayClientOrders APIPostgresorders + outboxEvent queueEmail, searchdedupe by id

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Database
  • Queue
  • Worker
Open in playground
Text description
  1. POST /orders arrives and passes validation.
  2. One transaction inserts the order and an outbox row. Both commit, or neither does.
  3. Commit ok.
  4. 201 Created goes back in about 30 ms. The client is done.
  5. The relay reads unsent outbox rows and publishes order.created, usually within a few hundred ms.
  6. Email and search consume it. Slow consumers never slow the request.

Press Play to watch it run · 6 steps

The API enqueues and answers 202; a worker does the slow part and stores a result the client can fetch.

The async path: job, worker, statusThe API enqueues and answers 202; a worker does the slow part and stores a result the client can fetch.POSTaddpullsave3 failsClientAPIJob queueWorker~40 s of workResult storeDead lettersfor a human

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Queue
  • Worker
  • Object storage
Open in playground
Text description
  1. POST /reports starts a 40 s job.
  2. The API adds job 77 to the queue.
  3. 202 Accepted with Location: /reports/77 comes back in a few ms.
  4. A worker pulls job 77 and starts the heavy part.
  5. The finished report is saved.
  6. The next poll of /reports/77 returns completed with a result URL.
How does the client learn the job is done?yesnoyesyesnonoSSE or WebSocketPoll the status URLDoes the user need liveprogress, not only theend?WebhookPoll the status URLIs the caller anotherserver that can host acallback URL?Is the caller a browsertab that stays open?

Is the caller a browser tab that stays open?

Failure path and retries

A dependency's outage shouldn't become yours.

Read more · 734 words

In a flash sale the recommendations service slows to 5 s. With no timeout, every checkout waits on it, threads pile up and the whole site goes down. With a 45 ms timeout and a fallback of popular items, checkout loads without recommendations.

  • Timeouts on every call.
  • Retries only for safe-to-repeat calls, with and a small budget.
  • A : after repeated failures, fail fast for a cooldown, then let one trial through.
  • A fallback: cached data or a smaller page. Slightly worse beats an error page.

Play the three scenarios in order: No timeout, Timeout + fallback, Breaker open.

  • Timeouts. A timeout turns an unbounded wait into a bounded one. By , requests in flight equal arrival rate times time spent: 100 req/s × 5 s = 500 stuck requests, against 100 × 0.045 = 4.5 with a 45 ms timeout. A pool of 200 threads fills in 200 / 100 = 2 s without one. Use a timeout on every network call, sized from the hop's , never the default (many clients wait 30 s or forever). The cost is false alarms: set it below the real p99 and healthy calls get cut.
  • Circuit breaker. It stops calling a service that keeps failing, so it can recover and so you stop wasting time on it. Closed means calls pass; open means they fail at once; half-open lets one trial through. After 5 failures in 10 s it opens for 30 s, and calls cost about 1 ms instead of 45 ms. The cost is a short window where good calls are refused, and one more thing to monitor. Use it on any call to a dependency that has a fallback. Raise the error rate in the simulator until it trips.
  • Fallback. The degraded answer you give when the real one is unavailable: popular items instead of personal ones, a cached price, an empty recommendations strip. Use it for anything the page can live without. Never use it for the payment itself; failing fast with a clear error beats guessing. Say out loud what the fallback is, because "it degrades gracefully" with no detail earns no credit.

Retries and amplification. A retry fixes a blip and deepens an outage. One lost packet and the second try works; a database already failing gets hit by every retry on top of the original traffic. Retries are the most common way an outage grows. They also multiply across layers. A client that tries 3 times, a gateway that tries 3 times and a service that tries 3 times send 3 × 3 × 3 = 27 attempts at the database for one user action. Normal load of 1,000 calls/s becomes 27,000/s at the worst moment. Play both scenarios.

Retry in one layer only, usually the one closest to the failing call. The others pass the error up.

Wait a growing, random time between tries, and cap the total. Without jitter, 1,000 clients that failed together retry together: at 100 ms, then 200 ms, 1,000 requests land in the same millisecond. With full jitter (a random wait between 0 and the cap) the same 1,000 spread across the window, about 10 per millisecond over 100 ms.

Add a retry budget: retries may be at most 10% of calls. At 1,000 req/s that allows 100 extra calls a second, so the database sees 1,100 at worst, not 3,000. Try it in the simulator and compare no retries, fixed delay and jitter.

Only retry what is safe to repeat: reads, PUT, DELETE, and a POST that carries an (next section). A card charge without a key is not safe to repeat.

A circuit breaker is a fuse box that flips its switch off when too much current surges in, cutting the wire to protect what is behind it.

Press Play to watch it run · 3 steps

Without a timeout, one slow service ties up every thread. With a timeout, a fallback and a breaker, checkout stays fast.

A slow dependency, with and without protectionWithout a timeout, one slow service ties up every thread. With a timeout, a fallback and a breaker, checkout stays fast.callfallbackShoppers100 req/sCheckout API200 threadsRecommendationsslow: 5 sPopular items~1 ms

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Cache
Open in playground
Text description
  1. A flash sale sends 100 checkouts a second.
  2. Recommendations slows to 5 s. Every checkout waits for it, so 100 req/s x 5 s means 500 requests in flight.
  3. The 200 threads are all taken after 200 / 100 = 2 s. Checkout stops answering, though it never needed recommendations.
Interactive

Circuit breaker

Requests (5 per second) pass through a breaker. Raise the dependency's error rate to trip it open, then lower it and watch a trial request close it.

5 failuresin a rowcooldownovertrialfailstrial succeedsClosedfailures 0/5● currentOpenfail fastHalf-openone trial callRecent requests (newest →)

✓ success · ✕ real error (dependency called) · ↩ fast-fail, fallback served

✓ Successes
0
↩ Fast-failures
0
fallback served
✕ Real errors
0
dependency called
Calls spared
0%
never reached it

Closed: calls go to the dependency (0 of 5 failures in a row so far). 0 successes, 0 fast-failures served by the fallback, 0 real errors.

State changes

No state changes yet: raise the error rate until 5 requests in a row fail.

Press Play to watch it run · 4 steps

Three layers that each try 3 times send 27 attempts at a database that is already failing.

Retries multiply across layersThree layers that each try 3 times send 27 attempts at a database that is already failing.x3x9x27Client3 triesGateway3 triesService3 triesDatabasefailing

Tap any box to see what it is and what it talks to.

  • Client
  • API gateway
  • Service
  • Database
Open in playground
Text description
  1. One user action starts at the client, which will try up to 3 times.
  2. The database is failing. Each client try makes the gateway call the service 3 times: 3 x 3 = 9.
  3. Each service call tries the database 3 times: 9 x 3 = 27 calls for one user action.
  4. Normal load is 1,000 database calls a second. During the outage it is 27,000, so the database cannot recover.
Interactive

Retry storm

300 clients fail at once; the server is back at 10 s and answers 20 requests per 100 ms.

Retry policy
Peak after recovery
165 / 100 ms
full jitter: 12
Everyone served by
not by 30 s
280 failing · full jitter: 17.5 s
Requests sent
1,790
6.0 per client
Requests arriving at the server per 100 ms (line: recovery at 10 s)
  • Exponential
  • Full jitter
050100150200req07.51522.429.9Time (s)ExponentialFull jitter
Show data table
Requests arriving at the server per 100 ms (line: recovery at 10 s)
Time (s)Exponential (req)Full jitter (req)
0135143
0.1165184
0.2028
0.3029
0.4037
0.5033
0.6034
0.7048
0.8049
0.9044
113533
1.116528
1.2012
1.3017
1.4015
1.5018
1.6018
1.7018
1.8024
1.9015
2030
2.1025
2.2023
2.3019
2.4016
2.5017
2.6017
2.709
2.8013
2.9012
313512
3.11658
3.2022
3.303
3.4017
3.5010
3.604
3.709
3.8011
3.9012
406
4.1015
4.2010
4.3012
4.409
4.509
4.609
4.7015
4.8011
4.907
5014
5.109
5.2010
5.3011
5.406
5.505
5.6010
5.706
5.805
5.907
608
6.108
6.2012
6.308
6.404
6.505
6.604
6.708
6.806
6.904
71355
7.11652
7.2011
7.306
7.4011
7.504
7.609
7.706
7.808
7.9010
8013
8.105
8.207
8.3011
8.404
8.5010
8.6012
8.707
8.808
8.908
9010
9.109
9.203
9.304
9.407
9.505
9.609
9.707
9.8011
9.909
1004
10.106
10.207
10.3011
10.4012
10.509
10.607
10.708
10.806
10.906
11011
11.109
11.207
11.309
11.405
11.505
11.608
11.707
11.805
11.903
1208
12.105
12.203
12.305
12.406
12.505
12.606
12.705
12.803
12.907
1304
13.105
13.201
13.303
13.402
13.504
13.605
13.701
13.806
13.902
1402
14.103
14.202
14.302
14.404
14.503
14.604
14.703
14.802
14.903
151352
15.11658
15.203
15.300
15.404
15.503
15.603
15.700
15.800
15.901
1602
16.100
16.203
16.300
16.401
16.503
16.600
16.701
16.803
16.901
1702
17.100
17.200
17.300
17.400
17.501
17.600
17.700
17.800
17.900
1800
18.100
18.200
18.300
18.400
18.500
18.600
18.700
18.800
18.900
1900
19.100
19.200
19.300
19.400
19.500
19.600
19.700
19.800
19.900
2000
20.100
20.200
20.300
20.400
20.500
20.600
20.700
20.800
20.900
2100
21.100
21.200
21.300
21.400
21.500
21.600
21.700
21.800
21.900
2200
22.100
22.200
22.300
22.400
22.500
22.600
22.700
22.800
22.900
231300
23.11600
23.200
23.300
23.400
23.500
23.600
23.700
23.800
23.900
2400
24.100
24.200
24.300
24.400
24.500
24.600
24.700
24.800
24.900
2500
25.100
25.200
25.300
25.400
25.500
25.600
25.700
25.800
25.900
2600
26.100
26.200
26.300
26.400
26.500
26.600
26.700
26.800
26.900
2700
27.100
27.200
27.300
27.400
27.500
27.600
27.700
27.800
27.900
2800
28.100
28.200
28.300
28.400
28.500
28.600
28.700
28.800
28.900
2900
29.100
29.200
29.300
29.400
29.500
29.600
29.700
29.800
29.900

Exponential: the retries stay synchronised, so the recovering server's worst 100 ms saw 165 requests (it can answer 20) against 12 with full jitter. At 30 s, 280 clients were still failing; with jitter everyone was served by 17.5 s.

How the model works

Exponential delays are 1, 2, 4, 8 s, then 8 s (capped); full jitter waits a random time between 0 and that. Past its capacity the server wastes time on rejections, so it answers fewer. Each run uses a seeded random stream: reshuffle for another.

Quick checkThree layers each retry 3 times. Attempts reaching the database?

Idempotency and lost replies

A phone on weak signal taps Pay.

Read more · 561 words

The charge succeeds, the reply is lost, the app times out, and the user taps again. Without a key, that is two charges. Reads are safe to repeat, but every payment or order write needs an : an id made once per operation and reused on every retry.

The server claims the key atomically (SET key pending NX), stores the result, and replays it for repeats. A duplicate arriving mid-flight gets 409 Conflict, not a second charge. Play Lost reply, then Retry replays, then Duplicate in flight.

  • Where the key comes from. The client makes the key, once per user intent, and reuses it for every retry of that intent. A UUID made when the user opens the Pay screen works. A key made fresh on each retry is useless, because the server sees a new request each time. Scope the stored key by user (idem:userId:key), as the code below does, so one user's key can never collide with another's.
  • How long to keep it. About 24 h. It must outlive the longest retry window (a mobile app may retry for minutes, a queue for hours), but not forever: at 100 payments a minute, 24 h of keys is 144,000 entries of a few hundred bytes each, a small cost for .
  • When the first attempt failed. If the charge threw an error, delete the key so the client can retry, which the code does in its catch. If you keep a stored failure, the client replays the failure forever. Also reject a reused key with a different body: the same key must always mean the same request.

A multi-step request can finish half its steps and fail on the rest, and the caller cannot see which half. The worst case in a shop: the card is charged and the order is not saved. Pick a tab in the diagram to see three versions.

  • Charge, then save leaves a hole. If the database times out after the charge, the customer paid 49.00 and has no order, and the error page invites them to pay again. Never do the irreversible step before the durable one.
  • Intent first, then reconcile. Write the order as pending first, charge using the order id as the key, then mark it paid. If the API dies in between, the order stays pending. A sweeper job looks for pending orders older than 5 minutes, asks the provider what happened to that key, and finishes the order or cancels it. Use it for anything that spans your database and an outside system, which cannot share one .
  • Unknown outcome. A timeout does not mean failure. After 45 ms with no reply you do not know whether the charge happened, so do not guess. Retry with the same key and let the provider return the original result. Also report the real state to the user: "we are checking your payment", not "payment failed".
Idempotency is a PAID stamp on a receipt: stamping it twice still leaves it paid once.

Press Play to watch it run · 5 steps

The API claims the key once, charges once, and replays the stored result to every retry.

An idempotency key through a lost replyThe API claims the key once, charges once, and replays the stored result to every retry.POSTclaimchargePhone appkey k1Payments APIRedisidem:k1Card provider

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Cache
  • External API
Open in playground
Text description
  1. The user taps Pay. The app sends POST /payments with Idempotency-Key k1.
  2. SET idem:k1 pending NX succeeds, so this request owns the key.
  3. The card is charged once.
  4. The result is saved under k1 for 24 h.
  5. The 200 is lost on weak signal. The app times out after 3 s and cannot tell if the charge happened.

Press Play to watch it run · 5 steps

Charging then saving can leave a paid card and no order. Record the intent first, and ask the provider when the outcome is unknown.

When some steps succeed and a later one does notCharging then saving can leave a paid card and no order. Record the intent first, and ask the provider when the outcome is unknown.alt [Charge, then save]POST /orderscharge 49.00paidINSERT orderThe database times out: the card is charged, no order exists500 error
  • Client
  • Service
  • External API
  • Database
Text description
  1. App → Orders API: POST /orders
  2. Orders API → Card provider: charge 49.00
  3. Card provider replies to Orders API: paid
  4. Orders API → Postgres: INSERT order
  5. Orders API replies to App: 500 error

Quick checkA charge times out with no reply. What should the API do?

Code

Read path

read-path.js
// Read path: CDN first, then cache, then database. Each layer absorbs traffic.
app.get('/products/:id', async (req, res) => {
  const product = await getProduct(req.params.id);   // Redis hit in ~1 ms, or the DB on a miss
  if (!product) return res.sendStatus(404);
  res.set('Cache-Control', 'public, max-age=60');     // CDN and browser may reuse it for 60 s
  res.json(product);
});

Write path

write-path.js
// Write path: validate, write the order AND the event in one transaction, respond. Consumers do the rest.
app.post('/orders', async (req, res) => {
  const order = validateOrder(req.body);                       // reject bad input before touching the database
  if (!order.ok) return res.status(400).json({ errors: order.errors });
  const id = crypto.randomUUID();
  await db.tx(async t => {                                     // both rows commit, or neither does
    await t.none('INSERT INTO orders(id, user_id, total) VALUES($1, $2, $3)', [id, req.user.id, order.total]);
    await t.none('INSERT INTO outbox(topic, payload) VALUES($1, $2)', ['order.created', { id, total: order.total }]);
  });
  res.status(201).location(`/orders/${id}`).json({ id });     // caches and search are updated from the event
});

Outbox relay

outbox-relay.js
// Outbox relay: publish committed events, then mark them sent. Delivery is at least once, so consumers dedupe by id.
const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function relayBatch(limit = 100) {
  return db.tx(async t => {
    const rows = await t.any(
      'SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT $1 FOR UPDATE SKIP LOCKED',   // several relays can run
      [limit],
    );
    for (const r of rows) await queue.publish(r.topic, { eventId: r.id, ...r.payload });
    if (rows.length) await t.none('UPDATE outbox SET sent_at = now() WHERE id IN ($1:csv)', [rows.map(r => r.id)]);
    return rows.length;                                     // a crash before this commit means a re-send, never a loss
  });
}

for (;;) {
  const sent = await relayBatch().catch(() => 0);
  if (sent === 0) await sleep(200);                         // idle: poll gently (or use CDC / LISTEN instead)
}

Async path

async-path.js
// Async path: 202 + Location, a status endpoint, and a worker with retries (BullMQ)
import { Queue, Worker } from 'bullmq';

const reports = new Queue('reports', { connection });

app.post('/reports', async (req, res) => {
  const job = await reports.add('build', req.body, { attempts: 3, backoff: { type: 'exponential', delay: 5_000 } });
  res.status(202).location(`/reports/${job.id}`).json({ jobId: job.id });
});

app.get('/reports/:id', async (req, res) => {
  const job = await reports.getJob(req.params.id);
  if (!job) return res.sendStatus(404);
  const state = await job.getState();                    // waiting | active | completed | failed | delayed
  res.json({ state, progress: job.progress, result: state === 'completed' ? job.returnvalue : undefined });
});

new Worker('reports', async job => {
  const rows = await loadRows(job.data);
  await job.updateProgress(50);
  return { url: await renderAndUpload(rows) };           // stored as job.returnvalue
}, { connection });                                      // after 3 failed attempts the job stays in the failed set (the DLQ)

Failure path

import { withTimeout } from './with-timeout.js';

// Failure path: a slow or broken recommender must not take the home page down with it
export async function recommendationsFor(userId, { recommend, popularFromCache }) {
  try {
    return await withTimeout(recommend(userId), 300);
  } catch {
    return popularFromCache();                            // degrade gracefully
  }
}

Idempotent retries

Read more · 51 words
idempotent-payment.js
// Idempotent retries. The source let two concurrent requests with the same key both charge the card.
app.post('/payments', async (req, res) => {
  const idemKey = req.get('Idempotency-Key');
  if (!idemKey) return res.status(400).json({ error: 'Idempotency-Key header required' });
  const key = `idem:${req.user.id}:${idemKey}`;
  const claimed = await redis.set(key, 'pending', 'EX', 86_400, 'NX');     // only one request can claim the key
  if (!claimed) {
    const prev = await redis.get(key);
    return prev === 'pending'
      ? res.status(409).json({ error: 'Payment with this key is in progress' })
      : res.json(JSON.parse(prev));                                      // replay the stored result
  }
  try {
    const result = await charge(req.body, { idempotencyKey: idemKey });  // the payment provider dedupes too
    await redis.set(key, JSON.stringify(result), 'EX', 86_400);
    res.json(result);
  } catch (e) {
    await redis.del(key);                                                // let the client retry after a failure
    throw e;
  }
});

Request flows in the interview

Bring request flows up when the interviewer says "walk me through" a request, or once your boxes are on the board.

Read more · 567 words

Say the decisions in this order; it shows each box has a job.

  1. Pick the route for each request

    Say which requests are reads, writes, async jobs or payments, and that each takes a different path. "Browsing is a cached read; checkout is a write with an ."

  2. Walk the read path with numbers

    , , then the database, with : "80% CDN and 90% Redis leave 2% of reads for Postgres."

  3. Make the write durable once

    One for the row and an event, a 201, and that skip duplicates. Name what you do not do: no publish after commit.

  4. Give every hop a budget

    A target, a timeout per hop with headroom, and a propagated deadline. Then name the fallback for the one dependency that may fail.

  5. Own retries and tracing

    Retries in one layer, with and a budget, safe because of keys. Finish with a trace id in every log line.

If the interviewer saysYou raise
"Walk me through a purchase"Gateway, idempotency key, one transaction with an outbox row, then a 201
"The report takes 40 seconds"202 with a status URL, a queue, a worker, a dead letter queue
"A downstream service is slow"A timeout, a breaker, and a fallback
"The user paid twice"Idempotency key claimed with SET NX
"Which hop makes it slow?"Distributed tracing: the widest span

Quick checkWhat do you say first when walking through a request?

Interview questions

Walk me through what happens when a user clicks 'Buy'.

, gateway (auth, ), then the order service. It checks the , writes the order plus an row in one , and answers 201. A relay fans the event out to email and analytics.

How do you set a latency budget?

Split the user-facing across the hops, with headroom. Enforce it with timeouts and a deadline.

The client timed out, but the charge went through. What now?

It retries with the same key and gets the stored result.

Why not just publish the event right after the database commit?

A crash between the two loses the event. The outbox commits it with the data.

Some requests are slow, but only sometimes. How do you find the hop responsible?

Distributed tracing: the widest span in a slow trace is the culprit.

Check yourself

Question 1 of 5

Score 2 out of 3 or better to mark this page complete.

1. Which status code means 'accepted for async processing'?