Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Beginner/B6
6 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDB6 · Part 1 · Beginner

APIs & communication

The API is the coupling surface — every round trip, every version and every retry is a design decision someone will live with for years.

22 min read3 diagrams9 sections0 exercises
6 of 36

In this chapter

  • An API is not a format question, it is a coupling question
  • REST vs gRPC vs GraphQL, at the system level
  • Versioning: you evolve an API, you rarely version it
  • Sync vs async: when a call should have been an event
  • Real-time transports: the decision table you will need

and 4 more

about 22 min
09On this page
  1. An API is not a format question, it is a coupling question
  2. REST vs gRPC vs GraphQL, at the system level
  3. Versioning: you evolve an API, you rarely version it
  4. Sync vs async: when a call should have been an event
  5. Real-time transports: the decision table you will need
  6. Idempotency keys, and retries that don't multiply
  7. Pagination: why offset breaks and cursors don't
  8. API gateways: what belongs there and what emphatically doesn't
  9. Recognizing it in an unseen problem

An API is not a format question, it is a coupling question

Candidates treat "REST or GraphQL?" as a taste debate. It isn't. The API is the seam between teams that deploy independently, and the choice determines three things you cannot easily change later: how many round trips a client needs, who has to redeploy when a field changes, and whether an intermediary can cache the response. Everything else — JSON versus protobuf, verbs versus procedures — is downstream of those. The reason interviewers spend fifteen minutes here is that the answer reveals whether you have ever had to evolve an API that mobile clients from two years ago are still calling.

chatty client aggregated phone API phone gateway services the 4th round trip is off-screen ~2 ms each 4 × 120 ms = 480 ms 120 ms + 8 ms = 128 ms
The dominant cost is the number of times you cross the slow link, not the bytes. Moving the fan-out from the phone to the datacenter is worth more than any serialisation format you could choose.

REST vs gRPC vs GraphQL, at the system level

REST / JSON over HTTPgRPC / protobufGraphQL
CouplingClient couples to resource shapes and URL structureClient couples to a generated stub; compatibility is a mechanical rule set (never reuse a field number)Client declares the shape it wants; server owns the graph. Loosest client coupling of the three
Over-fetchingEndemic. Every client gets the union of every client's needsSame as REST unless you add field masksSolved by construction — the client asks for four fields and gets four fields
Round trips for a composite screenOne per resource, often sequential because of id dependenciesOne per call; streaming can amortiseOne, always
Payload / CPUVerbose text; parsing is measurable on low-end phonesBinary, so typically smaller on the wire and cheaper to encode and decode than JSON; the factor depends on the data, so measure before quoting oneJSON, so REST-like, but you only ship what was asked for
Intermediary cachingFree. URL + Cache-Control means CDNs and proxies work with no effort, and ETag with If-None-Match gives cheap revalidation (If-Match gives optimistic concurrency on updates)None. Opaque bodies over HTTP/2 POST-like streamsHard. Everything is POST /graphql; you need persisted queries served over GET to get any edge caching back
Browser supportNativeNot native — needs grpc-web plus a translating proxyNative
Failure surfaceHTTP status codes carry meaningRich status codes, and deadlines that can propagate across hops (on by default in Go and Java, opt-in in C++, so check your stack)HTTP 200 with an errors array — partial success is normal and your monitoring must understand it
Reach for this when…Public APIs, third-party integrations, anything cacheable at the edge, anything a stranger must integrate against from a doc pageInternal service-to-service calls where latency, payload size and strict contracts matter, and both ends are yoursMany diverse clients (iOS, Android, web, TV) over one domain graph, shipping on different release cadences

The honest architecture at most large companies is all three at once: gRPC between internal services, a GraphQL or BFF layer for first-party clients, and REST at the public edge because that is what partners can integrate against without a codegen toolchain. Saying that — and saying why each boundary picked what it picked — is a stronger answer than defending one of them everywhere.

⚠ GraphQL's two bills come due in production First, the N+1 resolver problem: a query for 50 posts each with an author naively issues 1 + 50 database queries, because each resolver runs independently. The fix is per-request batching (DataLoader), and if you propose GraphQL without mentioning it, expect the follow-up. Second, a client can write a query that costs you a datacenter — deeply nested, wide-fanning, perfectly valid. You need query depth limits, static cost analysis with a budget per caller, and for first-party clients, persisted queries so only hashes of pre-approved documents are executable.

Versioning: you evolve an API, you rarely version it

Every version you ship you maintain forever, because the client that stops calling /v1 is the client that uninstalls the app. That makes additive, backward-compatible evolution the default and a version bump the failure case. The rules are boring and absolute: adding an optional field is safe; adding a required field, removing a field, renaming a field, narrowing a type, or changing the meaning of an existing value are all breaking, even when the tests pass.

ApproachMechanicsCostReach for this when…
Additive onlyNew optional fields; deprecate old ones with telemetry, never delete while usedField sprawl and dead codeAlways, as the baseline. Most "we need v2" is really "we need three more fields"
URL version (/v2/orders)Whole new surface, routed separatelyYou now run two implementations, or one with branchingA genuine model change — the resource means something different now
Header / media-type versionAccept: application/vnd.acme.v2+jsonInvisible in logs and caches unless you add Vary; harder for partners to useInternal APIs where clean URLs matter and clients are sophisticated
Field-level evolution (protobuf)Numbered fields, all optional; readers ignore unknownsRequires discipline: a reused field number is silent data corruptiongRPC internals — this is the mechanism that lets thousands of services skew by months
Expand / contract migrationWrite both old and new fields, migrate readers, then stop writing the old oneThree deploys and a waiting period per changeAny rename or reshape in a system with independently deployed clients

Mobile is what makes this hard, and it is worth saying explicitly because it reframes the whole discussion. A web client is whatever you deployed thirty seconds ago. A mobile client is a distribution: an app-store rollout takes days, adoption tails for months, and some non-trivial slice of users never updates. So the design constraints are (1) the server must tolerate every client version simultaneously, forever, (2) you need per-version telemetry to know when a field is genuinely dead, and (3) a server-driven kill switch or forced-upgrade path is a feature you should have shipped in v1.

Sync vs async: when a call should have been an event

A synchronous call says "wait here while I do this, and while everything I depend on does its part." That is the right shape when the user's next action depends on the result. It is the wrong shape far more often than people write it.

everything synchronous one sync hop, four events checkout charge 250 ms email 180 ms warehouse 300 ms hard dep hard dep hard dep checkout charge 250 ms publish event email analytics warehouse loyalty 830 ms · 0.999^5 = 99.50% 252 ms · 0.999^2 = 99.80%
Moving four calls off the request path cut latency 3.3× and removed four dependencies from the availability product. The event bus itself stays on the path as one more hard dependency (the second 0.999), which is why the right-hand figure is 99.80% and not 99.90%. Nothing got faster — the work simply stopped happening while the user waited.

Four symptoms tell you a request/response call should have been an event:

  • The caller ignores the response. If the return value is discarded, or wrapped in a try/catch that swallows the error, you have written an event with extra steps and worse failure semantics.
  • New consumers force a producer change. When "also notify the loyalty service" means editing checkout, the coupling is backwards. Publishing order.placed once and letting consumers subscribe is the entire point.
  • The callee's availability became yours. Every synchronous hop enters the availability product. Five three-nines dependencies in series cap you at 99.50% before you write any of your own code.
  • The work outlives the request budget. Video transcoding, PDF generation, bulk import. Return 202 Accepted with a status URL, and let the client poll or subscribe.
Say it like this → "I'd keep the payment authorisation synchronous, because the user has to be told yes or no before they leave the page. Everything else — receipt email, analytics, warehouse pick, loyalty points — becomes an order.placed event. That takes checkout from 830 ms (the three services drawn take 730 ms; I assume analytics and loyalty add about 50 ms each) and five hard dependencies down to 250 ms, one hard dependency on the payment path, plus the event bus. Adding a sixth consumer later doesn't touch the checkout service at all."

Then say the cost, unprompted, or you sound naive: asynchronous means at-least-once delivery, so consumers must be idempotent; it means ordering is only guaranteed within a partition key; it means the user's receipt now arrives "soon" rather than "now" and someone in product has to agree to that; and it means debugging a broken flow requires distributed tracing rather than a stack trace. Say how the event is published, too. Charging a card and then publishing to the bus are two separate writes, and a crash between them loses the event (or, in the other order, announces an order that never happened). The standard fix is the transactional outbox: write the event into an outbox table in the same database transaction as the order and let a relay publish it. Delivery is then at-least-once, and "exactly once" in practice means at-least-once plus idempotent consumers.

Real-time transports: the decision table you will need

Nearly every real-time design question — chat, notifications, live scores, collaborative editing, order tracking — bottoms out in this choice. Do the arithmetic before picking. One million clients short-polling every five seconds is 200,000 requests per second, and if only 2% of polls have anything to return, 196,000 of those per second are pure overhead: TLS, headers, auth, a database lookup, and a 204.

short poll long poll SSE WebSocket ask, mostly nothing ask, server holds one stream, down only frames, both ways client · server client · server client · server client · server 4 trips, 3 empty 2 trips, 0 empty 1 trip, 4 pushes 1 upgrade, then free the cost moves from requests per second to open sockets held per box
Moving right along this row trades request volume for connection state. That is the actual decision: you are choosing whether your scaling problem is QPS or file descriptors.
TransportDirectionServer cost at 1M clientsReal costReach for this when…
Short pollingClient pulls200k rps at a 5 s interval, ~98% emptyWasted capacity and up to 5 s of stalenessUpdates are rare, latency tolerance is tens of seconds, and you want zero new infrastructure. Genuinely fine for "check order status"
Long pollingClient pulls, server holds1M held requests plus a re-request every ~30 s timeoutTies up a request slot per client; needs async server I/O or your thread pool diesYou need push semantics but must traverse hostile proxies and ancient clients. The compatibility fallback, not the target
SSEServer pushes, one way1M open HTTP responses; planning figure 250k-500k per tuned box, so a few boxesNo client→server channel on that stream; over HTTP/1.1 browsers cap about 6 connections per origin across all tabs (HTTP/2 negotiates around 100 streams instead)Feeds, notifications, live scores, LLM token streaming — anything where the client only listens. The browser reconnects automatically and sends Last-Event-ID, but replaying the missed events is the server's job: keep recent events by id
WebSocketFull duplexSame socket cost as SSE, plus connection-to-server routingYou leave HTTP behind: your own framing, auth refresh, heartbeats, reconnect and backpressure. Deploys disconnect everyone at onceGenuinely bidirectional and chatty: chat, multiplayer, collaborative editing, trading. Not "we might want push someday"
⚠ WebSockets make your stateless tier stateful A connected socket lives on one specific process. To deliver a message to user 7 you must know which box holds their socket — so you need a presence registry (user → connection → node) and a way to route a message to that node, usually a pub/sub fan-out where every node subscribes to the channels its connections care about. You also inherit sticky load balancing, connection draining on deploy, and a reconnect storm every time you roll the fleet: 1M clients reconnecting with no jitter is a self-inflicted DDoS. Choose WebSockets because you need duplex, not because they sound modern.

Idempotency keys, and retries that don't multiply

Any call that times out has three possible truths: it never arrived, it arrived and failed, or it arrived and succeeded and you lost the response. The client cannot tell them apart, so it retries, so every mutating endpoint must be safe to call twice. GET, PUT and DELETE are naturally idempotent. POST /payments is not, and that is where the money is.

The mechanism: the client generates a key per logical operation — not per attempt — and sends it in an Idempotency-Key header, the convention popularised by APIs such as Stripe's (which keeps keys for at least 24 hours). The header is still an IETF Internet-Draft (draft-ietf-httpapi-idempotency-key-header, revision 07 of October 2025, since expired), not an RFC. The server records the key before doing the work, in its own short transaction that commits immediately.

-- Step 1: claim the key. Its own transaction, committed at once.
-- UNIQUE (account_id, key). 1 row inserted = we own it; 0 rows = someone
-- else does, so SELECT the row and branch on state and request_hash.
INSERT INTO idempotency (account_id, key, request_hash, state, locked_until)
VALUES ($1, $2, $3, 'in_progress', now() + interval '30 seconds')
ON CONFLICT (account_id, key) DO NOTHING;

-- Step 2: do the work. A card charge happens at an external processor, outside
-- any transaction of yours: pass the processor a key derived from ours, so it
-- deduplicates even if we crash and retry.

-- Step 3: record the outcome. Local effects and the key update are atomic.
BEGIN;
  INSERT INTO payments (...) VALUES (...);
  UPDATE idempotency SET state = 'done', response = $4
   WHERE account_id = $1 AND key = $2;
COMMIT;

Why not claim the key and do everything in one transaction? In Postgres the uncommitted claim row is invisible to other sessions, and a second inserter of the same key does not fail fast: it blocks until the first transaction ends, then gets a unique violation. The "fast 409" would never happen and a retry would just hang behind the original. Committing the claim first makes the duplicate visible immediately.

Four details separate a working implementation from a plausible-sounding one, and interviewers probe all of them:

  • Bind the key to the request. Store a hash of the body. If the same key arrives with a different body, that is a client bug — return 422, never silently replay the old response or, worse, execute the new one.
  • Handle the concurrent duplicate. The retry may arrive while the original is still running. Because the claim is already committed, the insert is a no-op, the lookup shows in_progress, and the correct response is 409 so the client backs off (the IETF draft also recommends 409) — not a wait, which converts one slow request into two held connections.
  • Survive a crash mid-flight. If the server dies after the charge but before step 3, the row stays in_progress. The lease (locked_until) lets a later retry take over; it repeats the processor call with the same derived key, and the processor returns the original charge instead of making a second one. Without a lease, that key is stuck forever.
  • Give keys a lifetime and say what it is. 24 hours to 7 days is typical. After that the record is reaped and a replayed request would execute again — which is fine, because no sane client retries a day later, but you should be the one to point that out.

Dry run: an idempotency key across a timeout, a retry, and a concurrent duplicate

TimeEventIdempotency row (key K)Payment charged?Client sees
t0Request A arrives, key K, body hash H1; step 1 claims the key and commits(K, H1, in_progress, lease 30 s)not yetwaiting
t0 + 2 msRequest C races in with key K while A is in flightINSERT is a no-op (0 rows); lookup shows in_progress, same hashno new chargeserver returns 409, client backs off
t0 + 5 msA calls the processor with derived key K', which charges oncein_progressyes — one chargewaiting
t0 + 8 msA runs step 3: local payment record plus the row set to done with the responsestate = doneyes (unchanged)waiting
t0 + 4 sNetwork drops the response before it reaches the clientstate = doneyes (unchanged)client sees a timeout, not the response
t0 + 6 sClient retries: request B arrives, same key K, same hash H1row already exists, state = doneno new chargeserver returns the stored response — success
(crash variant) t0 + 6 msA's server dies after the processor charged but before step 3; the client retries after the lease expiresstill in_progress, lease expired, so B takes it overB repeats the processor call with K'; the processor returns the original chargeserver finishes step 3 and returns success

Notice what makes this safe, and what does not. The local database write and the outcome record are atomic (step 3), but the card charge is not: it lives at an external processor, outside your transaction. The processor's own idempotency key is what stops a second charge, and the committed claim row plus its lease are what let the server resume after a crash. The client cannot tell a lost response from a lost request, but the server never has to guess — it just answers "what did I already decide for key K?"

Retry hygiene is the other half. Retry only on timeouts, connection errors, 408, 429 and 5xx — never on other 4xx codes, which will fail identically forever. Use exponential backoff with full jitter, cap attempts, and add a retry budget (for example, stop retrying once retries exceed ~10% of requests, a common starting point) so a struggling dependency doesn't see its traffic multiplied at its worst moment; with two retries per request and no budget, it sees up to 3×. Pair retries with timeouts and a circuit breaker, and honour Retry-After. And remember that every layer that retries multiplies. Count attempts, not retries: a client, a gateway and a service that each make up to 3 attempts (2 retries) turn one user action into 3 × 3 × 3 = 27 backend calls, and the Google SRE book's example, where each of three layers retries 3 times (4 attempts), reaches 4³ = 64. Retry at one layer, not every layer.

Pagination: why offset breaks and cursors don't

LIMIT 20 OFFSET 100000 does not skip 100,000 rows — the engine reads and discards 100,020 rows to hand you 20. Cost grows linearly with page depth: page 1 is a sub-millisecond index read, page 5,000 is hundreds of milliseconds and climbing, and a crawler walking to page 50,000 is an accidental denial-of-service you built yourself.

The correctness problem is worse than the performance one, because it is silent. Offsets address positions, and positions shift. On a feed where new rows arrive constantly, three items inserted between the user fetching page 1 and page 2 pushes three items they already saw onto page 2 — guaranteed duplicates. A deletion does the reverse and silently skips items. Every infinite scroll that repeats posts is this bug.

Offset / limitCursor (keyset)
QueryORDER BY created_at DESC LIMIT 20 OFFSET nWHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20
Cost per pageO(offset + limit) — degrades with depthO(log n + limit) — identical on page 1 and page 50,000
Stability under writesDuplicates and skips whenever rows shiftStable: the cursor names a row, not a position
Jump to page 500Trivially supportedImpossible by design
Total countUsers expect one, and COUNT(*) on 500M rows is a scanUsually omitted, or an estimate from table statistics
RequiresA stable sortA unique total ordering — always append the primary key as a tiebreaker or duplicate timestamps will drop rows
Reach for this when…Small, bounded, human-browsed sets: an admin table of 400 rows, search results capped at 10 pagesFeeds, timelines, exports, event logs, any API a machine will walk end to end. This is the default for anything that grows
Make the cursor opaque Return the cursor as a base64 blob and treat it as your private encoding. Clients will otherwise parse it, depend on it, and freeze your sort order forever. Sign it if it encodes anything a caller could forge into scanning data they shouldn't see.

API gateways: what belongs there and what emphatically doesn't

A gateway is the one place every request passes through, which makes it the correct home for concerns that are identical for every request — and a catastrophic home for anything else, because it is also a single shared deployment across every team you have.

Belongs at the gatewayDoes not belong there
TLS termination and certificate managementBusiness logic of any kind
Authentication: validate the token once, inject a verified identityAuthorization decisions services then trust blindly — services must re-check; the gateway is not a security boundary on its own
Coarse rate limiting and quota enforcement per callerPer-resource rules that need domain knowledge
Routing, canary and blue/green traffic splitsResponse aggregation with domain semantics — that's a BFF, and it should be a real service owned by the client team
Trace-id injection, structured access logs, request size limitsData transformation that changes when a product changes
Protocol translation at the edge (REST in, gRPC out)Anything whose change requires a gateway deploy to ship a feature

Two consequences to state out loud. First, the gateway is on the critical path for 100% of traffic, so its availability is the ceiling on everything behind it — it must be horizontally scaled, multi-AZ, and boringly simple. Second, the moment teams start putting logic in it you have built a distributed monolith with a shared release train: forty teams queueing behind one config repo, and an outage in the gateway is an outage in every product. The BFF pattern is the escape hatch — one thin aggregation service per client type (iOS, web, partner), owned by the team that consumes it, so mobile's needs never distort the public API and vice versa.

Recognizing it in an unseen problem

  • The words "live", "real-time", "instant", "as soon as it happens" mean the interviewer wants the polling/long-poll/SSE/WebSocket decision — with the request-per-second arithmetic, not a preference. Ask first how stale is acceptable; "30 seconds" turns a WebSocket design into polling.
  • "Mobile app" is a versioning and round-trip prompt. Say that old clients live forever, that you evolve additively, and that you would collapse the composite screen into one aggregated call.
  • A naive design makes every internal call synchronous and never mentions timeouts, retries or idempotency — then designs a retry loop that quietly double-charges customers.
  • Any mutating endpoint involving money, inventory or messages is an idempotency question in disguise. Volunteer the key before you're asked.
  • "Show the user their history / feed / all their orders" with a large dataset is a cursor-pagination prompt. Offset in an interview reads as never having operated a table past a few million rows.
  • Distinguish this from the message queue topic: this chapter is about the shape of a call between two parties; queues are about durability, ordering and buffering once you've decided the call should be asynchronous. Name the boundary and move on rather than re-deriving both.
Before you move on
  • Can you explain why the choice between REST, gRPC and GraphQL is a coupling decision, not a format preference?
  • Can you name the four symptoms that mean a synchronous call should have been an event?
  • Can you trace an idempotency key through a timeout, a client retry, and a concurrent duplicate request?
  • Can you explain why offset pagination breaks under concurrent writes while cursor pagination doesn't?
  • Can you say what belongs at an API gateway and what doesn't, and why authorization decisions can't stop there?
Sources and further reading
  • IETF draft: the Idempotency-Key HTTP header — still a draft (revision 07, October 2025), with 409 for concurrent duplicates and 422 for a changed payload
  • Stripe API: idempotent requests — the reference practice: saved responses, parameter comparison, 24-hour key pruning
  • PostgreSQL docs: index uniqueness checks — a second inserter of the same key waits for the first transaction to finish
  • Transactional outbox pattern — publishing an event atomically with a database change
  • Google SRE book: addressing cascading failures — retry amplification (the 64x example), retry budgets and jittered backoff
  • Google SRE book: handling overload — the retry budget of about 10% of requests
  • gRPC: deadlines — deadline propagation is on by default only in some languages
  • MDN: using server-sent events — automatic reconnection and the per-domain connection cap on HTTP/1
  • RFC 9110: HTTP semantics — idempotent methods, status codes and conditional requests

Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

B6

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. B5Previous · B5Caching fundamentals
  2. B6you are here
  3. Next · B7Walkthrough: a URL shortenerThe full 45-minute arc on the classic warm-up question — where every number is shown, every choice is defended, and the trap is the click counter.28 min read

01On this page

9 sections · 0%

  1. 01An API is not a format question, it is a coupling question
  2. 02REST vs gRPC vs GraphQL, at the system level
  3. 03Versioning: you evolve an API, you rarely version it
  4. 04Sync vs async: when a call should have been an event
  5. 05Real-time transports: the decision table you will need
  6. 06Idempotency keys, and retries that don't multiply
  7. 07Pagination: why offset breaks and cursors don't
  8. 08API gateways: what belongs there and what emphatically doesn't
  9. 09Recognizing it in an unseen problem

[] chapters · t top