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.
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 | 3-10× smaller on the wire, materially cheaper to encode and decode | 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 | 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, deadlines that propagate across hops | 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.
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.
order.placed event. That
takes checkout from 830 ms and five hard dependencies down to 250 ms and
one, and 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.
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; a tuned box holds 100-500k | No client→server channel on that stream; older HTTP/1.1 stacks cap 6 connections per origin | Feeds, notifications, live scores, LLM token streaming — anything where the client only listens. Auto-reconnect and Last-Event-ID replay come free |
| 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" |
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 as a header. The server makes the key part of the transaction.
BEGIN;
-- UNIQUE (account_id, idempotency_key). This insert is the lock.
INSERT INTO idempotency (account_id, key, request_hash, state)
VALUES ($1, $2, $3, 'in_progress'); -- duplicate → unique violation
-- the real work, in the SAME transaction, so it can never
-- happen without the key being recorded, or vice versa
INSERT INTO payments (...) VALUES (...);
UPDATE idempotency SET state = 'done', response = $4 WHERE key = $2;
COMMIT;
Three details separate a working implementation from a plausible-sounding one, and interviewers probe all three:
- 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. The unique constraint rejects it, and the correct response is
409 in progressso the client backs off — not a wait, which converts one slow request into two held connections. - 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.
Retry hygiene is the other half. Retry only on timeouts, connection errors,
429 and 5xx — never on 4xx, which
will fail identically forever. Use exponential backoff with full
jitter, cap attempts, and add a retry budget (abort retrying entirely
if retries exceed ~10% of requests) so a struggling dependency doesn't
receive 4× traffic at its worst moment. Honour Retry-After. And
remember that every layer that retries multiplies: a client retrying 3× in
front of a gateway retrying 3× in front of a service retrying 3× turns one
user action into 27 backend calls. 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 |
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.