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.
- 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.
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.
| Flow | Responds with | Protected by |
|---|---|---|
| Read | 200 from CDN, cache or DB | CDN, cache, TTLs |
| Write | 201 after the commit | Validation, the outbox |
| Async | 202 + Location | A queue, retries |
| Failure | 200 degraded, or a fast 503 | Timeouts, breaker, fallback |
The default answer is the cached read for anything shared, and a transactional write for anything that changes data.
- Client
- CDN
- Service
- Cache
- Database
- Queue
Text description
- Browser → CDN: GET /products/42
- CDN replies to Browser: 200 from the edge (~20 ms)
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:
| Hop | p99 budget | Enforced by |
|---|---|---|
| TLS + CDN edge | 30 ms | Keep-alive, a nearby edge |
| Load balancer + gateway | 10 ms | Local JWT checks |
| Service logic | 20 ms | No blocking work |
| Redis | 5 ms | A command timeout |
| Database | 50 ms | Indexes, statement_timeout |
| Downstream service | 45 ms | A per-call timeout |
| Headroom | 40 ms | Retries, GC pauses |
| Total | 200 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.
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)
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01Tap any box to see what it is and what it talks to.
- API gateway
- Service
- Queue
- Worker
Text description
- The gateway creates trace 4bf9 and sends traceparent: 00-4bf9...-01 with the call.
- Orders opens its own span, keeps trace id 4bf9, and sends its span id as the new parent.
- 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.
| Layer | What happens | Use it for | Cost |
|---|---|---|---|
| CDN hit | Answers from an edge near the user in about 20 ms; the origin never sees the request. The cheapest read there is | Anything identical for every user and fine to be 60 s old: product pages, images, public API responses. Never per-user data | Staleness: after an edit, the edge keeps the old copy until max-age ends or you purge it |
| Redis hit | The request reached the origin, but the database stays idle: about 20 + 30 + 1 = 51 ms | Shared data that many users read and few change, with cache-aside as the default | A copy that can be stale until the TTL ends or a write deletes the key |
| Database read | A full miss pays everything: about 20 + 30 + 1 + 20 + 1 = 72 ms, and it refills Redis for the next reader | The rest | Latency, 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.
Tap any box to see what it is and what it talks to.
- Client
- CDN
- Service
- Cache
- Database
Text description
- GET /products/42 reaches the nearest edge.
- 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:
- Validate input first.
- In one transaction, insert the order and an row for the event.
- Respond
201 Created. - A relay publishes
order.createdfrom the outbox. - 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
201in 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, anoutboxtable 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: 3and 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.
Tap any box to see what it is and what it talks to.
- Client
- Service
- Database
- Queue
- Worker
Text description
- POST /orders arrives and passes validation.
- One transaction inserts the order and an outbox row. Both commit, or neither does.
- Commit ok.
- 201 Created goes back in about 30 ms. The client is done.
- The relay reads unsent outbox rows and publishes order.created, usually within a few hundred ms.
- Email and search consume it. Slow consumers never slow the request.
Tap any box to see what it is and what it talks to.
- Client
- Service
- Queue
- Worker
- Object storage
Text description
- POST /reports starts a 40 s job.
- The API adds job 77 to the queue.
- 202 Accepted with Location: /reports/77 comes back in a few ms.
- A worker pulls job 77 and starts the heavy part.
- The finished report is saved.
- The next poll of /reports/77 returns completed with a result URL.
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.
Tap any box to see what it is and what it talks to.
- Client
- Service
- Cache
Text description
- A flash sale sends 100 checkouts a second.
- Recommendations slows to 5 s. Every checkout waits for it, so 100 req/s x 5 s means 500 requests in flight.
- The 200 threads are all taken after 200 / 100 = 2 s. Checkout stops answering, though it never needed recommendations.
Tap any box to see what it is and what it talks to.
- Client
- API gateway
- Service
- Database
Text description
- One user action starts at the client, which will try up to 3 times.
- The database is failing. Each client try makes the gateway call the service 3 times: 3 x 3 = 9.
- Each service call tries the database 3 times: 9 x 3 = 27 calls for one user action.
- Normal load is 1,000 database calls a second. During the outage it is 27,000, so the database cannot recover.
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
pendingfirst, charge using the order id as the key, then mark itpaid. If the API dies in between, the order stayspending. 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".
Tap any box to see what it is and what it talks to.
- Client
- Service
- Cache
- External API
Text description
- The user taps Pay. The app sends POST /payments with Idempotency-Key k1.
- SET idem:k1 pending NX succeeds, so this request owns the key.
- The card is charged once.
- The result is saved under k1 for 24 h.
- The 200 is lost on weak signal. The app times out after 3 s and cannot tell if the charge happened.
- Client
- Service
- External API
- Database
Text description
- App → Orders API: POST /orders
- Orders API → Card provider: charge 49.00
- Card provider replies to Orders API: paid
- Orders API → Postgres: INSERT order
- 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: 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: 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: 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: 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 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.
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 ."
Walk the read path with numbers
, , then the database, with : "80% CDN and 90% Redis leave 2% of reads for Postgres."
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.
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.
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 says | You 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 5Score 2 out of 3 or better to mark this page complete.