B6 · 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.
09On this page
- 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
- Idempotency keys, and retries that don't multiply
- Pagination: why offset breaks and cursors don't
- API gateways: what belongs there and what emphatically doesn't
- 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.
REST vs gRPC vs GraphQL, at the system level
| REST / JSON over HTTP | gRPC / protobuf | GraphQL | |
|---|---|---|---|
| Coupling | Client couples to resource shapes and URL structure | Client 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-fetching | Endemic. Every client gets the union of every client's needs | Same as REST unless you add field masks | Solved by construction — the client asks for four fields and gets four fields |
| Round trips for a composite screen | One per resource, often sequential because of id dependencies | One per call; streaming can amortise | One, always |
| Payload / CPU | Verbose text; parsing is measurable on low-end phones | Binary, so typically smaller on the wire and cheaper to encode and decode than JSON; the factor depends on the data, so measure before quoting one | JSON, so REST-like, but you only ship what was asked for |
| Intermediary caching | Free. 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 streams | Hard. Everything is POST /graphql; you need persisted queries served over GET to get any edge caching back |
| Browser support | Native | Not native — needs grpc-web plus a translating proxy | Native |
| Failure surface | HTTP status codes carry meaning | Rich 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 page | Internal service-to-service calls where latency, payload size and strict contracts matter, and both ends are yours | Many 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.
| Approach | Mechanics | Cost | Reach for this when… |
|---|---|---|---|
| Additive only | New optional fields; deprecate old ones with telemetry, never delete while used | Field sprawl and dead code | Always, as the baseline. Most "we need v2" is really "we need three more fields" |
URL version (/v2/orders) | Whole new surface, routed separately | You now run two implementations, or one with branching | A genuine model change — the resource means something different now |
| Header / media-type version | Accept: application/vnd.acme.v2+json | Invisible in logs and caches unless you add Vary; harder for partners to use | Internal APIs where clean URLs matter and clients are sophisticated |
| Field-level evolution (protobuf) | Numbered fields, all optional; readers ignore unknowns | Requires discipline: a reused field number is silent data corruption | gRPC internals — this is the mechanism that lets thousands of services skew by months |
| Expand / contract migration | Write both old and new fields, migrate readers, then stop writing the old one | Three deploys and a waiting period per change | Any 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.
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.placedonce 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 Acceptedwith 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.
| Transport | Direction | Server cost at 1M clients | Real cost | Reach for this when… |
|---|---|---|---|---|
| Short polling | Client pulls | 200k rps at a 5 s interval, ~98% empty | Wasted capacity and up to 5 s of staleness | Updates are rare, latency tolerance is tens of seconds, and you want zero new infrastructure. Genuinely fine for "check order status" |
| Long polling | Client pulls, server holds | 1M held requests plus a re-request every ~30 s timeout | Ties up a request slot per client; needs async server I/O or your thread pool dies | You need push semantics but must traverse hostile proxies and ancient clients. The compatibility fallback, not the target |
| SSE | Server pushes, one way | 1M open HTTP responses; planning figure 250k-500k per tuned box, so a few boxes | No 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 |
| WebSocket | Full duplex | Same socket cost as SSE, plus connection-to-server routing | You leave HTTP behind: your own framing, auth refresh, heartbeats, reconnect and backpressure. Deploys disconnect everyone at once | Genuinely 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 is409so 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
| Time | Event | Idempotency row (key K) | Payment charged? | Client sees |
|---|---|---|---|---|
| t0 | Request A arrives, key K, body hash H1; step 1 claims the key and commits | (K, H1, in_progress, lease 30 s) | not yet | waiting |
| t0 + 2 ms | Request C races in with key K while A is in flight | INSERT is a no-op (0 rows); lookup shows in_progress, same hash | no new charge | server returns 409, client backs off |
| t0 + 5 ms | A calls the processor with derived key K', which charges once | in_progress | yes — one charge | waiting |
| t0 + 8 ms | A runs step 3: local payment record plus the row set to done with the response | state = done | yes (unchanged) | waiting |
| t0 + 4 s | Network drops the response before it reaches the client | state = done | yes (unchanged) | client sees a timeout, not the response |
| t0 + 6 s | Client retries: request B arrives, same key K, same hash H1 | row already exists, state = done | no new charge | server returns the stored response — success |
| (crash variant) t0 + 6 ms | A's server dies after the processor charged but before step 3; the client retries after the lease expires | still in_progress, lease expired, so B takes it over | B repeats the processor call with K'; the processor returns the original charge | server 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 / limit | Cursor (keyset) | |
|---|---|---|
| Query | ORDER BY created_at DESC LIMIT 20 OFFSET n | WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20 |
| Cost per page | O(offset + limit) — degrades with depth | O(log n + limit) — identical on page 1 and page 50,000 |
| Stability under writes | Duplicates and skips whenever rows shift | Stable: the cursor names a row, not a position |
| Jump to page 500 | Trivially supported | Impossible by design |
| Total count | Users expect one, and COUNT(*) on 500M rows is a scan | Usually omitted, or an estimate from table statistics |
| Requires | A stable sort | A 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 pages | Feeds, 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 gateway | Does not belong there |
|---|---|
| TLS termination and certificate management | Business logic of any kind |
| Authentication: validate the token once, inject a verified identity | Authorization 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 caller | Per-resource rules that need domain knowledge |
| Routing, canary and blue/green traffic splits | Response 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 limits | Data 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.
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.
Finished reading?
0 of 36 chapters read in this series