Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
B6

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.

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 phones3-10× smaller on the wire, materially cheaper to encode and decodeJSON, so REST-like, but you only ship what was asked for
Intermediary cachingFree. URL + Cache-Control means CDNs and proxies work with no effortNone. 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, deadlines that propagate across hopsHTTP 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^1 = 99.90%
Moving four calls off the request path cut latency 3.3× and removed four dependencies from the availability product. 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 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.

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; a tuned box holds 100-500kNo client→server channel on that stream; older HTTP/1.1 stacks cap 6 connections per originFeeds, notifications, live scores, LLM token streaming — anything where the client only listens. Auto-reconnect and Last-Event-ID replay come free
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 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 progress so 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 / 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.
←previousCaching fundamentals↑ CovernextWalkthrough: URL shortener→