System Design NotesRohit’s interview study guide
Chapter 07

APIs and Real-Time Communication

How the boxes talk: writing the API section, pagination, REST vs GraphQL vs gRPC, pushing data with polling, SSE and WebSockets, scaling connection servers, webhooks, rate limiting, idempotency keys and versioning.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Designing an API in the interview

Added

The API section is where the interview turns from "what should it do" into "what exactly goes in and comes out". It takes three to five minutes, right after the requirements and the estimates, and it does more work than it looks: it fixes the nouns of the system (which become tables and services), the operations (which become the read and write paths you'll draw), and the promises you make to callers (what happens on retry, how lists are paged, what errors look like). An interviewer who sees a clean API knows you've understood the problem before you draw a single box.

A good mental model: the API is the menu, not the kitchen. It lists what a customer can order and what they'll get back, in terms the customer understands. It says nothing about which chef cooks it, which fridge the ingredients came from or how many ovens there are. If your API leaks the kitchen (POST /runSqlQuery, GET /shard3/users), you can never rebuild the kitchen without breaking every customer. Name things after the domain, keep the storage behind the menu.

In practice you write one line per functional requirement: the method, the path, the important parameters, what comes back, and the status codes that matter. You don't write full JSON schemas; you write enough that the data model and the hard parts (pagination, idempotency, auth) are visible.

Here's what that looks like on the whiteboard for a photo-sharing app whose requirements were "post a photo, see a feed of people I follow, like a photo":

API section: photo sharing
POST   /v1/photos                         create a photo record, get an upload URL
       body { caption, contentType }       → 201 { photoId, uploadUrl } + Location: /v1/photos/{photoId}
       header Idempotency-Key: <uuid>

GET    /v1/photos/{photoId}               → 200 { photoId, ownerId, caption, url, likeCount, createdAt }
                                           → 404 if missing or not visible to the caller

GET    /v1/feed?limit=20&cursor=<opaque>  → 200 { items: [photo…], nextCursor | null }

PUT    /v1/photos/{photoId}/likes/me      like (idempotent: liking twice is still one like) → 204
DELETE /v1/photos/{photoId}/likes/me      unlike → 204

All calls: Authorization: Bearer <token>  · errors: { type, title, detail }  · 429 + Retry-After when limited
What’s happening
  1. Resources are nouns, operations are HTTP methods. photos, feed and likes are things; creating, reading and deleting them are POST, GET, PUT and DELETE. There's no /createPhoto or /getFeed: the method already says what happens, so the path only has to say to what.
  2. POST /v1/photos doesn't carry the image bytes. It creates the metadata row and returns a pre-signed uploadUrl, so the client uploads straight to object storage. That one line tells the interviewer you've thought about large uploads not flowing through your API servers. It returns 201 Created with a Location header pointing at the new resource, and it takes an Idempotency-Key header, because a mobile client on a bad network will retry it (see Idempotency keys for APIs).
  3. The feed is paged with an opaque cursor, not a page number. limit is bounded by the server, and the response carries nextCursor (or null at the end). Writing cursor here signals that you know offsets break on a feed that changes while it's read (see Pagination at the API level).
  4. Liking is modelled as a resource you PUT, not an action you POST. PUT /likes/me means "make it so that I like this photo"; doing it twice leaves the same state, so retries and double taps are harmless. POST /photos/{id}/like would usually be implemented as "add a like", and a retry would add two. Choosing the method is choosing the retry behaviour.
  5. Cross-cutting rules go on one line at the bottom: auth in a header (never in the URL, where it ends up in logs), one error shape for every endpoint, and 429 with Retry-After for rate limiting. The /v1 prefix says there's a versioning story (see API versioning and backwards compatibility).
  6. From these five lines the interviewer can already infer the data model (photos, follows, likes(photoId, userId) with a unique pair) and the two paths worth a deep dive: the feed read path and the write path for likes on a hot photo. That's the job of the API section.
AdvancedResources, paths and naming

The conventions that make an API predictable, and that interviewers notice when they're missing:

  • Plural nouns for collections, ids for members: /photos, /photos/{photoId}. One style everywhere; don't mix /photo/7 and /users/7.
  • Nest only to express ownership, and only one or two levels: /users/{userId}/photos is fine; /users/{u}/albums/{a}/photos/{p}/comments/{c} is a sign that comments/{c} should be a top-level resource with its own id.
  • Filters, sorting and paging are query parameters, not path segments: /photos?ownerId=7&sort=-createdAt&limit=20.
  • Actions that aren't CRUD are modelled as a sub-resource or a state change: POST /orders/{id}/cancellation or PATCH /orders/{id} { status: "cancelled" }. A plain verb like POST /orders/{id}/cancel is a common, acceptable compromise; say it's deliberate.
  • Ids are opaque strings ("ord_8Hx2"), not auto-increment integers exposed to the world: integers leak your volume and invite enumeration, and they're hard to keep unique once you shard.
  • Timestamps in UTC with an offset (ISO 8601, 2026-10-24T14:03:00Z), money as integer minor units plus a currency ({ "amount": 4200, "currency": "cad" }), never floats.
AdvancedMethods: safe, idempotent, and what a retry does

HTTP defines two properties per method, in RFC 9110 (HTTP Semantics, 2022). Safe means the client isn't asking for any change (GET, HEAD, OPTIONS). Idempotent means sending the same request twice has the same effect on the server as sending it once (the safe methods plus PUT and DELETE). Idempotency is what makes automatic retries possible: browsers, proxies, SDKs and service meshes retry idempotent requests on a broken connection, and they must not retry POST blindly.

MethodSafeIdempotentTypical useUsual success codes
GETyesyesread a resource or a list200, 304 (not modified)
POSTnonocreate in a collection, start a process201 + Location, 202 (async)
PUTnoyescreate or fully replace at a known id200, 204, 201 if it created
PATCHnonot by definitionpartial update200, 204
DELETEnoyesremove204, 202

PATCH is idempotent or not depending on what the patch says: "set qty to 3" is, "add 1 to qty" isn't.

AdvancedStatus codes worth knowing cold
CodeMeaningWhen to use it
200 / 201 / 204OK / Created / No Contentread or update / created (with Location) / success with no body
202Acceptedwork queued; return a job resource to poll, e.g. Location: /v1/exports/91
304Not Modifiedconditional GET with If-None-Match matched the ETag
400Bad Requestmalformed input the client must fix
401Unauthorizednot authenticated: no or bad credentials
403Forbiddenauthenticated, but not allowed
404Not Founddoesn't exist, or the caller mustn't know it exists
409Conflictstate conflict: duplicate, or a concurrent request with the same idempotency key
412Precondition FailedIf-Match with a stale ETag: someone else updated it first (optimistic locking)
422Unprocessable Contentwell-formed but semantically invalid (amount negative, key reused with a different body)
429Too Many Requestsrate limited; send Retry-After
500 / 502 / 503 / 504server error / bad upstream / overloaded or down / upstream timeout503 + Retry-After for planned load shedding

Use one error body everywhere. RFC 9457 (Problem Details for HTTP APIs, 2023, which replaced RFC 7807) defines a JSON shape with type, title, status, detail and instance; it's a good default to name in an interview.

AdvancedWhich calls survive a retry

The reason the method matters in a design interview is retries. This model applies every request on the server, then loses the first response, so the client retries exactly as a mobile app or an SDK would:

retry-safety.mjs
// Every request reaches the server and is applied, but the first response is lost.
// The client times out and sends the same request again. Which calls are safe to retry?
function freshServer() {
  const orders = new Map([[1, { id: 1, sku: "BOOK", qty: 1 }]]);
  let nextId = 2;
  const handle = (method, path, body) => {
    const id = Number(path.split("/")[2]);
    if (method === "GET") return orders.has(id) ? 200 : 404;
    if (method === "PUT") { orders.set(id, { id, ...body }); return 200; }
    if (method === "DELETE") return orders.delete(id) ? 204 : 404;
    if (method === "PATCH") { orders.get(id).qty += body.add; return 200; }
    if (method === "POST") { orders.set(nextId, { id: nextId++, ...body }); return 201; }
  };
  const snapshot = () => [...orders.values()].map((o) => `#${o.id}×${o.qty}`).join(" ") || "(empty)";
  return { handle, snapshot };
}

const calls = [
  ["GET", "/orders/1"],
  ["PUT", "/orders/1", { sku: "BOOK", qty: 3 }],
  ["DELETE", "/orders/1"],
  ["PATCH", "/orders/1", { add: 1 }],
  ["POST", "/orders", { sku: "PEN", qty: 1 }],
];

for (const [method, path, body] of calls) {
  const once = freshServer();
  once.handle(method, path, body);
  const twice = freshServer();
  const first = twice.handle(method, path, body); // response lost on the way back
  const retry = twice.handle(method, path, body); // the client's retry
  const same = once.snapshot() === twice.snapshot();
  console.log(
    `${method.padEnd(6)} once: ${once.snapshot().padEnd(14)} with retry: ${twice.snapshot().padEnd(14)}` +
      ` statuses ${first}→${retry}  ${same ? "safe to retry" : "NOT safe"}`,
  );
}
Result
GET    once: #1×1           with retry: #1×1           statuses 200→200  safe to retry
PUT    once: #1×3           with retry: #1×3           statuses 200→200  safe to retry
DELETE once: (empty)        with retry: (empty)        statuses 204→404  safe to retry
PATCH  once: #1×2           with retry: #1×3           statuses 200→200  NOT safe
POST   once: #1×1 #2×1      with retry: #1×1 #2×1 #3×1 statuses 201→201  NOT safe
What’s happening
  1. Each row builds two fresh servers holding order #1 with qty 1. On the first it sends the request once; on the second it sends it, "loses" the response, and sends it again. If the two final states match, a blind retry is harmless.
  2. GET changes nothing, so it's trivially safe. PUT with { qty: 3 } says "the order is this", so applying it twice still leaves #1×3. That's why PUT to a client-chosen id is the classic way to make a create idempotent.
  3. DELETE ends in the same state (empty) either way, so it's idempotent, but notice the status: 204 then 404. Idempotency is about the effect on the server, not the response. A client that treats 404 on a retried DELETE as an error will report a failure for something that succeeded; treat it as success.
  4. PATCH { add: 1 } is a relative change: once gives qty 2, with a retry it's 3. It's the same bug as "increment a counter" over an unreliable network. The fix is either an absolute patch ({ qty: 2 }) or a condition (If-Match with the ETag you read, so the retry gets 412).
  5. POST creates a new order every time: one call gives #2, a retry gives #2 and #3. For orders or payments that's a double charge. This is the gap idempotency keys close: the client sends the same key with the retry, and the server returns the first result instead of acting again.
AdvancedWhere this shows up, and what to say
  • Write the API before the boxes. It's cheap, it forces the nouns, and every later box has to serve one of these lines. If a box serves none of them, ask why it's there.
  • Mark the dangerous endpoints. Point at the POSTs that move money or send messages and say "this needs an idempotency key". Point at the list endpoints and say "cursor-paginated, max 100 per page".
  • Async work returns 202. Video transcoding, exports and bulk imports return a job resource immediately; the client polls it, gets a webhook, or listens on a stream.
  • Optimistic concurrency with ETag + If-Match (→ 412) prevents lost updates when two editors save the same document; mention it whenever two people can edit one thing.
  • Don't over-design it. No HATEOAS links, no full schemas, no 30 endpoints. Five to eight lines covering the functional requirements is right for a 45-minute round.
Advanced401 vs 403, and 200 with an error inside
Which status code for "start a video export"?

A client calls POST /v1/exports { videoId }. The export takes about four minutes. What should the API return, and how does the client find out it's finished?

Show answer

202 Accepted with a job resource: Location: /v1/exports/exp_91 and a body like { "exportId": "exp_91", "status": "queued" }.

What’s happening
  1. 201 Created would claim the export exists and is complete; it isn't. 200 with a four-minute wait would hold a connection and an HTTP worker for four minutes and hit proxy timeouts (60 s by default on many load balancers).
  2. 202 Accepted means "I've taken the request; the work happens later". The Location header gives the client a resource to check.
  3. The client then learns about completion in one of three ways: it polls GET /v1/exports/exp_91 (simple; use Retry-After to pace it), it registers a webhook that fires export.completed, or a connected UI listens on an SSE or WebSocket stream for the job's status.
  4. Because the create is a POST, it should also take an Idempotency-Key, so a retry after a timeout doesn't start a second four-minute export.

#Pagination at the API level

Added

Any endpoint that returns a list must return it in pages: an unbounded GET /orders is a time bomb that works in the demo and takes the database down the day a customer has two million orders. The two families are offset pagination ("skip 40, give me 20": ?page=3&size=20 or ?offset=40&limit=20) and cursor pagination ("give me 20 after this one": ?cursor=<opaque>&limit=20), also called keyset or seek pagination.

The mental model: offset pagination is "start at the 41st book on the shelf", counted from the left every time you come back. If someone slides a new book in at the left end while you're reading, the 41st book is now the one you already read, so you see it twice; if someone takes a book out, you skip one. Cursor pagination is a bookmark: "continue after this book". New books at the left end don't move your bookmark, and the database can jump straight to it with an index instead of counting.

That makes the choice mostly about the data. A list that changes while it's being read (feeds, timelines, logs, search over live data) wants cursors. A small, mostly static table where users jump to "page 7 of 12" (an admin screen) can live with offsets.

Here are both on the same newest-first feed, with a new post arriving while the reader is on page 1:

cursor-vs-offset.mjs
// A feed of posts, newest first. Stable order: createdAt DESC, then id DESC as a tie-breaker.
const posts = Array.from({ length: 10 }, (_, i) => ({ id: i + 1, createdAt: 1000 + i * 10 }));
const newestFirst = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const sorted = () => [...posts].sort(newestFirst);

// Offset pagination: "skip N rows, take LIMIT".
const offsetPage = (offset, limit) => sorted().slice(offset, offset + limit);

// Cursor pagination: the cursor encodes the sort key of the last row the client saw.
const encode = (row) => Buffer.from(JSON.stringify([row.createdAt, row.id])).toString("base64url");
const decode = (cursor) => JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
function cursorPage(cursor, limit) {
  let rows = sorted();
  if (cursor) {
    const [c, id] = decode(cursor); // WHERE (created_at, id) < (c, id)
    rows = rows.filter((p) => p.createdAt < c || (p.createdAt === c && p.id < id));
  }
  const page = rows.slice(0, limit);
  return { page, next: page.length === limit ? encode(page.at(-1)) : null };
}

const ids = (rows) => rows.map((p) => p.id).join(", ");
const LIMIT = 4;

const o1 = offsetPage(0, LIMIT);
const c1 = cursorPage(null, LIMIT);
console.log("page 1   offset:", ids(o1), "  cursor:", ids(c1.page), " next =", c1.next, "→", decode(c1.next));

posts.push({ id: 11, createdAt: 1200 }); // someone posts while the reader is on page 1
console.log("-- post 11 inserted at the top --");

const o2 = offsetPage(LIMIT, LIMIT);
const c2 = cursorPage(c1.next, LIMIT);
console.log("page 2   offset:", ids(o2), "  cursor:", ids(c2.page));
const o3 = offsetPage(2 * LIMIT, LIMIT);
const c3 = cursorPage(c2.next, LIMIT);
console.log("page 3   offset:", ids(o3), "     cursor:", ids(c3.page), " next =", c3.next);

const seen = (pages) => pages.flat().map((p) => p.id);
const dupes = (list) => list.filter((id, i) => list.indexOf(id) !== i);
console.log("offset saw", seen([o1, o2, o3]).length, "rows, duplicates:", dupes(seen([o1, o2, o3])));
console.log("cursor saw", seen([c1.page, c2.page, c3.page]).length, "rows, duplicates:", dupes(seen([c1.page, c2.page, c3.page])));
Result
page 1   offset: 10, 9, 8, 7   cursor: 10, 9, 8, 7  next = WzEwNjAsN10 → [ 1060, 7 ]
-- post 11 inserted at the top --
page 2   offset: 7, 6, 5, 4   cursor: 6, 5, 4, 3
page 3   offset: 3, 2, 1      cursor: 2, 1  next = null
offset saw 11 rows, duplicates: [ 7 ]
cursor saw 10 rows, duplicates: []
What’s happening
  1. Ten posts, ids 1–10, with createdAt 1000…1090, sorted newest first by createdAt DESC, id DESC. Page 1 is the same for both styles: posts 10, 9, 8, 7.
  2. The cursor for page 2 is the sort key of the last row the client saw, [1060, 7], JSON-encoded and then base64url-encoded into WzEwNjAsN10. The client never parses it; it just sends it back. That's what opaque means: the server can change what's inside without breaking anyone.
  3. Post 11 is inserted with the newest timestamp, so it goes to the very top. Offset page 2 is now "skip 4, take 4" of a list that shifted down by one: positions 5–8 are posts 7, 6, 5, 4, and post 7 is shown twice. In a feed UI that's a duplicate card; in an export job it's a duplicated row in a CSV someone will reconcile.
  4. Cursor page 2 asks for "rows strictly after (1060, 7) in sort order", which is createdAt < 1060, or the same createdAt with a smaller id. The new post 11 sorts before the bookmark, so it doesn't disturb anything: page 2 is 6, 5, 4, 3 and page 3 is 2, 1 with next = null, which tells the client it has reached the end.
  5. Totals: offset returned 11 rows for 10 posts with one duplicate; cursor returned exactly the 10 posts. The cursor reader doesn't see post 11 at all, and that's correct: it's newer than the page they started from. Feeds show it with a "1 new post" banner that reloads from the top.
  6. In SQL the cursor filter is a row-value comparison: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 21, served by an index on (created_at, id). The database seeks to the bookmark instead of reading and discarding OFFSET rows, so page 1,000 costs the same as page 1. Fetching limit + 1 rows is the usual trick to know whether there's a next page without a COUNT.
AdvancedWhy the tie-breaker matters

The sort key in a cursor has to be unique. Timestamps aren't: imports, batch jobs and busy systems create many rows in the same millisecond. Here are three ways to write the "after the last row" condition, on six orders where three share a timestamp:

cursor-ties.mjs
// Six orders; three were created in the same second (a batch import). Page size 2.
const rows = [
  { id: 1, createdAt: 100 }, { id: 2, createdAt: 200 }, { id: 3, createdAt: 200 },
  { id: 4, createdAt: 200 }, { id: 5, createdAt: 300 }, { id: 6, createdAt: 400 },
];
const byKey = (a, b) => b.createdAt - a.createdAt || b.id - a.id; // DESC, id breaks ties

// Each strategy decides which rows come "after" the last row of the previous page.
const strategies = {
  "createdAt <  last": (r, last) => r.createdAt < last.createdAt,
  "createdAt <= last": (r, last) => r.createdAt <= last.createdAt,
  "(createdAt, id) < last": (r, last) =>
    r.createdAt < last.createdAt || (r.createdAt === last.createdAt && r.id < last.id),
};

for (const [name, after] of Object.entries(strategies)) {
  const pages = [];
  let last = null;
  for (let n = 0; n < 6; n++) {
    const page = rows
      .filter((r) => last === null || after(r, last))
      .sort(byKey)
      .slice(0, 2);
    if (page.length === 0) break;
    pages.push(page.map((r) => r.id).join(","));
    last = page.at(-1);
  }
  const seen = pages.join(",").split(",").map(Number);
  const missing = rows.map((r) => r.id).filter((id) => !seen.includes(id));
  const note = pages.length === 6 ? " (stuck: same page forever)" : "";
  console.log(`${name.padEnd(23)} pages [${pages.join("] [")}]  missing: ${missing.join(",") || "none"}${note}`);
}
Result
createdAt <  last       pages [6,5] [4,3] [1]  missing: 2
createdAt <= last       pages [6,5] [5,4] [4,3] [4,3] [4,3] [4,3]  missing: 1,2 (stuck: same page forever)
(createdAt, id) < last  pages [6,5] [4,3] [2,1]  missing: none
What’s happening
  1. Orders 2, 3 and 4 share createdAt = 200. Sorted newest first with the id tie-breaker, the order is 6, 5, 4, 3, 2, 1.
  2. Strict < on the timestamp alone: page 2 ends on order 3 (createdAt 200). "After" then means createdAt < 200, which jumps over order 2, still unseen, with the same timestamp. Order 2 is silently skipped. Nobody notices until an accountant does.
  3. <= on the timestamp alone: page 1 ends on order 5, and <= 300 brings order 5 back, so page 2 repeats it. From page 3 on, the last row is order 3 at 200, and <= 200 returns 4 and 3 again, forever. The loop guard stops it at six pages; a real client would spin until a timeout.
  4. The tuple (createdAt, id) is unique because id is, so "after" is always well defined: pages [6,5] [4,3] [2,1], nothing missing, nothing repeated. Always append the primary key to the sort, both in ORDER BY and in the cursor.
AdvancedDesigning the pagination contract
  • Response shape: { "items": [...], "nextCursor": "..." | null }, or Stripe's style of has_more plus starting_after=<last object id>, or a Link: <...>; rel="next" header as GitHub's REST API uses. Pick one and use it on every list endpoint.
  • Bound the page size. Accept limit, default it (say 20), cap it (say 100), and document it. Google's API design guide (AIP-158, checked October 2026) uses a maximum of 1000 in its example and says the API should coerce larger values rather than fail.
  • Make cursors opaque, URL-safe, and validated. AIP-158 says page tokens must be opaque but URL-safe and not user-parseable, and that the other parameters of the request should match the call that produced the token (the page size may change), with INVALID_ARGUMENT if they don't. Base64 is an encoding, not protection: a client can decode WzEwNjAsN10 and forge one. Put the filter and sort in the cursor and check them, and sign it (HMAC) or encrypt it if a forged cursor could reveal data.
  • Let cursors expire. AIP-158 gives "a reasonable time", with three days as a rule of thumb. Expiring tokens lets you change the encoding later.
  • Avoid exact totals. COUNT(*) over a large filtered set is often slower than the page itself. Return hasMore, or an approximate total labelled as such.
  • Sorting by a mutable field is different. If the feed sorts by score or updatedAt, items move between pages while the user scrolls, and no cursor can prevent that; you either snapshot the ranking (cache the ranked id list for the session and page through it) or accept occasional repeats and de-duplicate on the client.
Offset (?page=3&size=20)Cursor (?cursor=…&limit=20)
Rows inserted or deleted while readingduplicates or skipped rowsstable
Cost of a deep pagereads and discards every skipped rowindex seek: same as page 1
Jump to page N, show "page 3 of 12"yesno, only next (and previous with a second cursor)
Works with any sortyesneeds a unique, indexed sort key
Typical fitsmall admin tables, search results with page numbersfeeds, timelines, exports, sync, infinite scroll
AdvancedA timestamp alone is not a cursor

#REST vs GraphQL vs gRPC

Added

These are three styles of request/response API, and in a design interview the question is never "which is best" but "which fits this caller". REST exposes resources at URLs and uses HTTP's own methods, status codes and caching; anything can call it, from curl to a CDN. GraphQL exposes one endpoint and a typed schema; the client sends a query naming exactly the fields it wants, across related objects, and gets that shape back in one round trip. gRPC is remote procedure calls with a .proto contract, compact binary Protobuf messages and HTTP/2 streams, built for service-to-service traffic.

A mental model: REST is a shop with labelled aisles: every product has a place, anyone can walk in, and the shelves (caches) can be restocked without asking the warehouse; but a long shopping list means walking many aisles. GraphQL is a personal shopper: hand over one list, get one bag with exactly those items; convenient for the customer, and the shopper does all the walking behind the counter. gRPC is an internal phone line with a codebook: very fast and precise between two departments that share the codebook, useless to someone off the street.

The Node.js course covers the gRPC wire format and a server in Node; here the question is what each style costs a real screen. The screen: "my last 5 orders", each with its product name and seller name.

api-styles.mjs
// One screen: "my last 5 orders", each showing the product name and the seller name.
const sellers = new Map([1, 2].map((id) => [id, { id, name: `Seller ${id}`, address: "x".repeat(300), rating: 4.8 }]));
const products = new Map([10, 11, 12, 13, 14].map((id, i) =>
  [id, { id, name: `Product ${id}`, sellerId: (i % 2) + 1, description: "x".repeat(600), price: 1999, images: ["a.jpg", "b.jpg"] }]));
const orders = [10, 11, 12, 13, 14].map((productId, i) => ({ id: 100 + i, productId, total: 1999 }));

let dbQueries = 0;
const db = {
  orders: async () => (dbQueries++, orders),
  products: async (ids) => (dbQueries++, ids.map((id) => products.get(id))),
  sellers: async (ids) => (dbQueries++, ids.map((id) => sellers.get(id))),
};
const bytes = (x) => Buffer.byteLength(JSON.stringify(x));

// REST: one resource per call; the client stitches the screen together.
async function rest() {
  dbQueries = 0;
  const responses = [await db.orders()];
  for (const o of orders) {
    const [p] = await db.products([o.productId]);
    const [s] = await db.sellers([p.sellerId]);
    responses.push(p, s); // full resources, every field
  }
  return { roundTrips: responses.length, bytes: responses.reduce((n, r) => n + bytes(r), 0), dbQueries };
}

// GraphQL: one round trip; resolvers fetch each field. `batch` turns on a DataLoader-style batcher.
function loader(fetchMany) {
  let queue = null;
  return (id) => new Promise((resolve) => {
    if (!queue) {
      queue = [];
      queueMicrotask(async () => {
        const batch = queue; queue = null;
        const rows = await fetchMany(batch.map((q) => q.id));
        batch.forEach((q, i) => q.resolve(rows[i]));
      });
    }
    queue.push({ id, resolve });
  });
}
async function graphql(batch) {
  dbQueries = 0;
  const product = batch ? loader(db.products) : async (id) => (await db.products([id]))[0];
  const seller = batch ? loader(db.sellers) : async (id) => (await db.sellers([id]))[0];
  const data = await Promise.all((await db.orders()).map(async (o) => {
    const p = await product(o.productId);
    const s = await seller(p.sellerId);
    return { id: o.id, product: { name: p.name, seller: { name: s.name } } }; // only the fields asked for
  }));
  return { roundTrips: 1, bytes: bytes({ data }), dbQueries };
}

for (const [name, run] of [["REST, resource per call", rest], ["GraphQL, naive resolvers", () => graphql(false)], ["GraphQL + batching", () => graphql(true)]]) {
  const r = await run();
  console.log(`${name.padEnd(25)} round trips ${String(r.roundTrips).padStart(2)}   bytes ${String(r.bytes).padStart(5)}   DB queries ${r.dbQueries}`);
}
Result
REST, resource per call   round trips 11   bytes  5451   DB queries 11
GraphQL, naive resolvers  round trips  1   bytes   370   DB queries 11
GraphQL + batching        round trips  1   bytes   370   DB queries 3
What’s happening
  1. The data: 5 orders, each pointing at a product, each product pointing at one of 2 sellers. Products carry a 600-byte description and sellers a 300-byte address: fields this screen never shows.
  2. REST, one resource per call: GET /orders, then for each order GET /products/{id} and GET /sellers/{id}: 1 + 5 + 5 = 11 round trips, and the client downloads full resources, 5,451 bytes, to display five names. That's under-fetching (not enough in one call, so more calls) and over-fetching (more fields than needed) at the same time. On a phone with a 100 ms round trip, eleven sequential calls is over a second of waiting; the calls per order can run in parallel, but the product call still has to finish before its seller call.
  3. GraphQL, naive resolvers: the client sends one query (orders { id product { name seller { name } } }) and gets back 370 bytes containing only those fields, in 1 round trip. But look at the server: each product field and each seller field ran its own database query, still 11 queries. The round trips didn't disappear; they moved from the phone to the data centre, where they're cheaper but can multiply: a list of 100 orders would be 201 queries. This is GraphQL's N+1 problem.
  4. GraphQL with batching: loader() is the DataLoader pattern. Each resolver asks for one id, but the loader only queues it and schedules one flush with queueMicrotask. All five product resolvers run in the same tick, so their ids go out as one products([10..14]) query; when those resolve, the five seller lookups queue up in the next tick and go out as one more. 3 queries for the whole screen. A real DataLoader also de-duplicates ids (here sellers 1 and 2 were requested five times) and caches them for the rest of the request.
  5. What this shows: GraphQL's big win is for clients, especially many different screens on slow networks, which get exactly their shape in one call. The cost lands on the server, which needs batching, query cost limits and its own caching. A REST API can close most of the gap with a purpose-built endpoint (GET /me/order-summaries) or a backend-for-frontend, at the price of one endpoint per screen.
AdvancedHow the three compare
RESTGraphQLgRPC
Shaperesources at URLs, HTTP methodsone endpoint, typed schema, client-chosen fieldsservices and methods from a .proto
Payloadusually JSON, readableJSON, exactly the requested fieldsbinary Protobuf, compact, needs the schema to read
TransportHTTP/1.1, HTTP/2 or HTTP/3HTTP (usually POST); subscriptions over WebSocketHTTP/2
HTTP caching and CDNsbuilt in for GET (Cache-Control, ETag)hard: one URL, POST bodies; persisted queries over GET helpnone
Errorsstatus codesusually 200 with an errors array, per fieldgRPC status codes (NOT_FOUND, DEADLINE_EXCEEDED)
Streamingnot built in (SSE, WebSockets, chunked responses)subscriptionsclient, server and bidirectional streams
Browsersnativenativeneeds gRPC-Web and a proxy
Evolving the APIversions or additive changesadd fields, deprecate old ones; no versions neededadd fields with new numbers; never reuse a number
Best fitpublic and partner APIs, CRUD, cacheable readsmany client screens over related data, mobile, a BFFinternal high-volume calls, polyglot services, streaming
AdvancedWhat GraphQL costs on the server

A GraphQL endpoint lets clients compose queries you didn't write, so it needs guards a REST endpoint gets for free from its fixed shape:

  • Query cost limits. A nested query can fan out to millions of objects. GitHub's GraphQL API (checked October 2026) gives each user 5,000 points per hour, computes a query's cost from the number of objects each connection could return, rejects calls requesting more than 500,000 total nodes, requires first or last between 1 and 100 on every connection, and terminates requests that take more than 10 seconds. Copy that shape: bounded page sizes, a cost per query, a budget per client.
  • Depth limits and persisted queries. Public endpoints often only accept queries registered at build time (persisted queries, sent by hash). That blocks abusive queries and makes GET caching possible again.
  • Batching everywhere (the DataLoader above), or the database pays for every nested field.
  • Monitoring by operation name, because every request hits the same URL: per-endpoint dashboards built on HTTP paths see one giant endpoint.
AdvancedA common combined architecture

Large systems often use all three, each where it fits: REST for the public and partner API (stable, cacheable, easy to document and rate-limit per endpoint); GraphQL as a gateway or backend-for-frontend that web and mobile apps query, which fans out to internal services; and gRPC between internal services, where contracts are shared, payloads are small and frequent, and deadlines propagate across calls. Saying this in the interview shows you see them as tools rather than camps.

AdvancedGraphQL errors arrive with 200 OK

#Real-time options: polling, long polling, SSE, WebSockets

Added

Plain HTTP is request/response: the client asks, the server answers, and the server can't speak first. Anything "live" (a chat message, a price tick, a delivery driver's position, a build log, an AI answer streaming in) needs a way around that. There are four, and they trade request volume for connection state: short polling asks again every few seconds; long polling asks once and the server holds the request open until there's news (or a timeout); Server-Sent Events (SSE) keep one HTTP response open and stream events down it; WebSockets upgrade the connection into a two-way channel where either side sends messages at any time.

A mental model from a car journey: short polling is the child asking "are we there yet?" every five minutes; long polling is asking once and the parent answering only when you arrive (or after half an hour, "not yet, ask again"); SSE is the radio: tune in once and the station keeps talking, but you can't talk back on the same channel; a WebSocket is a phone call: either side talks whenever it wants, for as long as the line stays up.

Short polling ask every 5 s client server empty empty update news (late) empty empty Long polling server holds it client server news at once timeout: empty request held open SSE one stream, down only client server GET /stream event event event WebSocket two-way, after upgrade client server Upgrade → 101 client sends msg msg
Same updates (pink dots), four techniques. Polling pays in empty requests and delay; the other three pay in connections held open.

The cost of each is easiest to see as numbers. Assume 1 million open pages, each getting an update about once a minute:

realtime-cost.mjs
// Assumptions (state them out loud in the interview):
const clients = 1_000_000;      // concurrently open pages
const updateEvery = 60;         // seconds between updates for one client, on average (random arrivals)
const pollEvery = 5;            // short polling interval, seconds
const longPollTimeout = 30;     // the server answers "nothing new" after this long
const heartbeatEvery = 30;      // SSE comment (3 B) or WebSocket ping + pong (8 B) keeps proxies from closing idle connections
const httpOverhead = 700;       // bytes of request + response headers per HTTP exchange (rule of thumb; cookies make it far bigger)
const payload = 200;            // bytes of useful data per update
const sseFraming = 20;          // "id: …\ndata: …\n\n" around the payload
const wsFraming = 4;            // server-to-client frame header for a small message (2-10 bytes in RFC 6455)

const updatesPerSec = clients / updateEvery;
// Long polling: each held request ends at the next update or at the timeout, whichever is first.
// With random (exponential) update arrivals, the mean hold time is updateEvery × (1 − e^(−timeout/updateEvery)).
const meanHold = updateEvery * (1 - Math.exp(-longPollTimeout / updateEvery));

// [name, requests or heartbeats per second, bytes on the wire per second, connections held open, extra delay]
const rows = [
  ["short polling", clients / pollEvery, (clients / pollEvery) * httpOverhead + updatesPerSec * payload, 0, pollEvery / 2],
  ["long polling", clients / meanHold, (clients / meanHold) * httpOverhead + updatesPerSec * payload, clients, 0],
  ["SSE", clients / heartbeatEvery, updatesPerSec * (payload + sseFraming) + (clients / heartbeatEvery) * 3, clients, 0],
  ["WebSocket", clients / heartbeatEvery, updatesPerSec * (payload + wsFraming) + (clients / heartbeatEvery) * 8, clients, 0],
];

const fmt = (n) => Math.round(n).toLocaleString("en-US");
console.log(`updates to deliver: ${fmt(updatesPerSec)}/s; long-poll mean hold ${meanHold.toFixed(1)} s`);
console.log("approach        requests/s (or pings/s)       MB/s  held open   average delay");
for (const [name, perSec, bytesPerSec, open, delay] of rows) {
  const useful = name.includes("polling") ? ` (${((updatesPerSec / perSec) * 100).toFixed(0)}% carry news)` : " (heartbeats)";
  console.log(`${name.padEnd(15)} ${fmt(perSec).padStart(7)}${useful.padEnd(18)} ${(bytesPerSec / 1e6).toFixed(1).padStart(7)}  ${fmt(open).padStart(9)}   ${delay ? `+${delay} s` : "network only"}`);
}
Result
updates to deliver: 16,667/s; long-poll mean hold 23.6 s
approach        requests/s (or pings/s)       MB/s  held open   average delay
short polling   200,000 (8% carry news)     143.3          0   +2.5 s
long polling     42,358 (39% carry news)     33.0  1,000,000   network only
SSE              33,333 (heartbeats)          3.8  1,000,000   network only
WebSocket        33,333 (heartbeats)          3.7  1,000,000   network only
What’s happening
  1. The work that must happen regardless is 16,667 updates per second (1,000,000 clients ÷ 60 s). Everything else in the table is overhead the technique adds on top.
  2. Short polling at 5 s is 1,000,000 ÷ 5 = 200,000 requests per second, and only 8% of them carry news; the rest are full HTTP exchanges (700 bytes of headers here, much more with cookies) answering "nothing new", for 143 MB/s. Worse, an update waits on average half an interval, +2.5 s, before the next poll picks it up. Its one advantage: no connections are held between polls, and every poll is a plain, cacheable, load-balanced GET.
  3. Long polling holds each request until an update or the 30 s timeout. With updates arriving at random once a minute, the mean hold is 60 × (1 − e^(−30/60)) = 23.6 s, so each client makes about one request per 23.6 s: 42,358 requests/s, with 39% carrying news. Delivery is immediate, but the server now holds a million open requests, and every update costs a full new HTTP exchange.
  4. SSE and WebSockets pay for the connection once and then send only framing: about 20 bytes around each SSE event, 2–10 bytes for a WebSocket frame. The only extra traffic is a heartbeat every 30 s to stop proxies and load balancers from closing idle connections: 33,333 tiny pings per second and about 3.8 MB/s in total, roughly 38× less bandwidth than short polling for the same updates.
  5. The trade-off the table makes visible: polling turns real-time into request load, the persistent techniques turn it into connection state. A million held connections is a different scaling problem (memory per connection, servers that can't be restarted casually, reconnection storms), which is what Scaling WebSockets is about.
  6. If you changed updateEvery to 3,600 (hourly updates) and pollEvery to 300 (five-minute polls), polling would cost about 3,333 requests/s with no held connections, and that's often the right answer: if the requirement tolerates minutes of delay, don't build a real-time system.
AdvancedShort and long polling

Short polling is the default to beat: a setInterval and a GET, cacheable, stateless, load-balanced like any request. It's right when updates are rare or delay is fine (an order status page, an export job). Use ETag/If-None-Match so "nothing new" is a 304 with no body, and have the server send Retry-After to slow clients down under load.

Long polling gives near-instant delivery with nothing but plain HTTP, which is why it was the original fallback in libraries like Socket.IO and still works behind the strictest corporate proxies. Its traps are timeouts and ordering: every hop must allow the hold time (an AWS Application Load Balancer closes connections idle for 60 seconds by default, and nginx's proxy_read_timeout is also 60 seconds by default, both checked October 2026), so keep the server's hold under them (25–30 s is common). Events that happen between one response and the next request must be buffered and returned on the next poll, so the client sends "the last event id I saw".

AdvancedServer-Sent Events

SSE is a normal GET whose response has Content-Type: text/event-stream and never ends. The server writes text events (id:, event:, data: lines, a blank line between events), and the browser's EventSource parses them, reconnects automatically, and on reconnect sends the last id: it saw in a Last-Event-ID header so the server can resume. A retry: field sets the reconnect delay in milliseconds. It's one-way (server to client); the client talks back with ordinary requests. It's the natural fit for notifications, live scores, dashboards, progress bars and streaming AI responses token by token.

The resume protocol is the part worth designing. Here's the server side with a small replay buffer, and the client's parsing:

sse-resume.mjs
// Server side of Server-Sent Events: keep the last few events so a reconnecting client can catch up.
const BUFFER = 5;
const recent = []; // [{ id, data }], oldest first
let nextId = 1;

function publish(data) {
  recent.push({ id: nextId++, data });
  if (recent.length > BUFFER) recent.shift();
}

// What the server writes on a (re)connection. lastEventId comes from the Last-Event-ID request header.
function onConnect(lastEventId) {
  if (lastEventId === undefined) return "retry: 3000\n\n"; // fresh client: just set the reconnect delay
  const oldest = recent[0].id;
  if (lastEventId < oldest - 1) return "event: resync\ndata: gap too big, reload state\n\n";
  return recent.filter((e) => e.id > lastEventId).map((e) => `id: ${e.id}\ndata: ${e.data}\n\n`).join("");
}

// Client side: what EventSource does with the stream text.
function parse(stream) {
  return stream.split("\n\n").filter(Boolean).map((block) => {
    const msg = { event: "message" };
    for (const line of block.split("\n")) {
      const [field, ...rest] = line.split(": ");
      msg[field] = rest.join(": ");
    }
    return msg;
  });
}

for (const score of ["1-0", "1-1", "2-1"]) publish(`score ${score}`);
let lastSeen = 3;                                   // the client has seen ids 1..3, then its Wi-Fi drops
publish("score 3-1");
publish("red card");                                // ids 4 and 5 happen while it is offline

console.log("fresh connect  :", JSON.stringify(onConnect(undefined)));
const replay = parse(onConnect(lastSeen));
console.log("resume after 3 :", replay.map((m) => `#${m.id} ${m.data}`).join(" | "));
lastSeen = Number(replay.at(-1).id);

for (const e of ["2-2", "3-2", "3-3", "4-3", "5-3", "6-3"]) publish(`score ${e}`); // ids 6..11, buffer keeps 7..11
console.log("buffer now     :", recent.map((e) => e.id).join(","));
console.log("resume after 5 :", parse(onConnect(lastSeen)).map((m) => `${m.event}: ${m.data}`).join(" | "));
console.log("resume after 6 :", parse(onConnect(6)).map((m) => `#${m.id}`).join(" "));
Result
fresh connect  : "retry: 3000\n\n"
resume after 3 : #4 score 3-1 | #5 red card
buffer now     : 7,8,9,10,11
resume after 5 : resync: gap too big, reload state
resume after 6 : #7 #8 #9 #10 #11
What’s happening
  1. The server gives every event an increasing id and keeps the last 5 in recent. A brand-new client gets only retry: 3000, which tells EventSource to wait 3 s before reconnecting after a drop.
  2. The client saw events 1–3, then lost its connection; events 4 and 5 were published while it was away. EventSource reconnects on its own and sends Last-Event-ID: 3. The server finds both missed events still in the buffer and writes them in order: #4 score 3-1 | #5 red card. The user sees nothing missing.
  3. Then six more events push the buffer to ids 7–11. A client that last saw id 5 is missing id 6, which has been evicted. The server can't replay a gap, so it sends a named resync event telling the client to reload the full state (fetch the current score) and continue from there. Detecting the gap is the key: silently replaying 7–11 would leave the client wrong without knowing it.
  4. A client that last saw id 6 needs exactly 7–11, all buffered, so it gets them. The condition lastEventId < oldest - 1 is what separates "fully covered" from "gap".
  5. In a real system the buffer is per stream (per user, per room or per topic) and lives somewhere every server can read (a Redis stream, a Kafka topic, or the database with an index on id), because the client usually reconnects to a different server. The same resume-or-resync idea applies to WebSockets, which have no built-in Last-Event-ID, so you build it into your own protocol.
AdvancedWebSockets: what you take on

WebSockets

A WebSocket (RFC 6455) starts as an HTTP request with Upgrade: websocket; the server answers 101 Switching Protocols, and from then on the TCP connection carries frames in both directions with a few bytes of overhead each, text or binary. It's the right tool when the client also sends frequently and latency matters: chat, multiplayer games, collaborative editing, trading screens.

What you take on in exchange:

  • No automatic reconnection or resume. The client library must reconnect with backoff and jitter, and your protocol needs sequence numbers so the client can ask for what it missed (the same buffer idea as SSE).
  • Your own heartbeats. Ping/pong every 20–30 s, both to keep proxies from closing idle connections and to detect dead peers (a phone that went into a tunnel doesn't send a close frame).
  • Auth at the handshake, and again later. Browsers can't set an Authorization header on the WebSocket constructor, so the token goes in a cookie or a short-lived ticket in the URL or first message; long-lived connections also need a way to expire or re-check it.
  • Not plain HTTP any more. No caching, no per-request load balancing, no status codes per message, and every L7 proxy in the path must support the upgrade. Over HTTP/2, RFC 8441 defines WebSockets as a stream on a shared connection, but HTTP/1.1 upgrade is still the common path.
Short pollingLong pollingSSEWebSocket
Directionclient asksclient asks, server answers when readyserver → clientboth ways
Latencyup to one intervalabout one network tripabout one network tripabout one network trip
Server costmany requestsone held request per clientone open response per clientone open connection per client
Reconnect and resumenot neededby handbuilt in (Last-Event-ID)by hand
Through proxiesanywhereneeds long timeoutsneeds no buffering, long timeoutsneeds upgrade support
DataanythinganythingUTF-8 texttext or binary
Typical fitrare updates, job statusfallback, restrictive networksnotifications, feeds, dashboards, AI streamingchat, games, collaboration

When the app is closed, none of these work: mobile apps use push notifications (APNs, FCM) and servers use webhooks. The browser-side details (reconnecting with backoff, pausing in background tabs) are in Real-time updates in the client.

#Scaling WebSockets

Added

A stateless HTTP service scales by adding servers behind a load balancer: any server can answer any request. WebSockets break that, because each connection lives on one particular server for minutes or hours. When Bob sends a message to a group, the members' sockets are spread over many servers, and the server that received Bob's message holds only Bob's socket. Scaling WebSockets is solving two problems: fan-out (getting one message to every server that holds a recipient) and routing and presence (knowing who is connected, and where).

The standard answer splits the system in two. Connection servers (also called gateways or edge servers) do nothing but hold sockets: authenticate the handshake, keep a map of socket → user → subscriptions, send heartbeats, and push frames. Everything else (chat logic, storage, permissions) is ordinary stateless services. Between them sits a pub/sub backplane such as Redis pub/sub, NATS or Kafka: a connection server subscribes to the channels its users care about, and any service publishes to a channel without knowing which servers hold whom.

The mental model is a hotel with switchboard operators in several buildings. Each operator only has the phones in their own building wired to their desk. A message for a guest goes onto the hotel intercom (the backplane) tagged with the guest's group; every operator whose building has a guest in that group picks it up and rings the right phones. Nobody needs a list of which guest is in which building; operators just listen for the groups they currently serve.

Alice Bob Chen Load balancer passes Upgrade Gateway A socket: Alice Gateway B socket: Bob Gateway C socket: Chen Pub/sub backplane Redis · NATS room:7 user:42 Chat service Message store Presence user → gateway, TTL ① ⑥ ② ③ ④ ⑤ ⑤ heartbeats
① Alice sends on her socket. ② Gateway A hands it to the chat service, which ③ saves it, then ④ publishes on room:7. ⑤ Only gateways with room-7 members subscribed, so it reaches B and C (and A, which acknowledges to Alice; arrow not drawn), and ⑥ they push it to Bob and Chen.

The backplane's job is fan-out, and how servers subscribe decides how much traffic it carries. This model puts 1,000 users on 10 servers in 200 five-person group chats and sends 1,000 messages two ways:

ws-fanout.mjs
// 1,000 users connected to 10 WebSocket servers; 200 group chats of 5 members. Seeded, so it is repeatable.
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(7);
const pick = (n) => Math.floor(rand() * n);

const SERVERS = 10, USERS = 1000, ROOMS = 200;
const serverOf = Array.from({ length: USERS }, () => pick(SERVERS)); // the load balancer's choice
const members = Array.from({ length: ROOMS }, () => new Set(Array.from({ length: 5 }, () => pick(USERS))));

// The backplane (think Redis pub/sub): channel -> set of subscribed servers.
class Backplane {
  subs = new Map();
  deliveries = 0;
  subscribe(channel, server) {
    if (!this.subs.has(channel)) this.subs.set(channel, new Set());
    this.subs.get(channel).add(server);
  }
  publish(channel, msg, onDeliver) {
    for (const server of this.subs.get(channel) ?? []) { this.deliveries++; onDeliver(server, msg); }
  }
}

function run(mode) {
  const bp = new Backplane();
  for (let s = 0; s < SERVERS; s++) {
    if (mode === "every server gets everything") bp.subscribe("all", s);
    else members.forEach((m, room) => { if ([...m].some((u) => serverOf[u] === s)) bp.subscribe(`room:${room}`, s); });
  }
  let pushes = 0, wasted = 0;
  const r = mulberry32(42);
  for (let i = 0; i < 1000; i++) {
    const room = Math.floor(r() * ROOMS);
    bp.publish(mode === "every server gets everything" ? "all" : `room:${room}`, { room }, (server, msg) => {
      const local = [...members[msg.room]].filter((u) => serverOf[u] === server); // sockets on this server
      pushes += local.length;
      if (local.length === 0) wasted++;
    });
  }
  console.log(`${mode.padEnd(30)} backplane deliveries ${String(bp.deliveries).padStart(6)}  wasted ${String(wasted).padStart(5)}  socket pushes ${pushes}`);
}

const room7 = [...members[7]].map((u) => `u${u}@S${serverOf[u]}`).join(" ");
console.log("room 7 members:", room7);
run("every server gets everything");
run("servers subscribe per room");
Result
room 7 members: u619@S7 u57@S6 u945@S6 u400@S6 u200@S3
every server gets everything   backplane deliveries  10000  wasted  5999  socket pushes 4996
servers subscribe per room     backplane deliveries   4001  wasted     0  socket pushes 4996
What’s happening
  1. The seeded PRNG (mulberry32) makes the run repeatable. Each user lands on a random server, as a load balancer would place them, and each room gets 5 random members. Room 7 shows the typical spread: its members sit on servers 7, 6 (three of them) and 3, so a message to room 7 must reach three of the ten servers.
  2. "Every server gets everything" subscribes all 10 servers to one channel. Each of the 1,000 messages is delivered to all 10: 10,000 backplane deliveries, of which 5,999 were wasted: the server received the message, checked its local sockets, and found no member of that room. This is what a naive Socket.IO-style broadcast through Redis does, and it means every server processes every message in the system. Adding servers doesn't reduce per-server load; it multiplies backplane traffic.
  3. "Subscribe per room": a server subscribes to room:N only while it holds at least one member of room N (in a real system, on join and on socket connect; unsubscribe when the last local member leaves). Now a message reaches only servers with recipients: 4,001 deliveries, none wasted, about 4 servers per message for 5-member rooms spread over 10 servers.
  4. Socket pushes are 4,996 in both cases: the users receive the same messages either way. The difference is purely in how much the servers and the backplane do to get them there. (It's 4,996 rather than 5,000 because a few rooms drew the same user twice and have only 4 members.)
  5. The trade-off: per-room subscriptions mean many channels and subscription churn as users connect and leave; for a few huge broadcast channels (a live event with a million viewers), every server should subscribe, because nearly every server has a viewer. Slack's design (below) goes further and assigns channels to stateful channel servers with consistent hashing.
AdvancedHow many servers, and what a failure costs

Connection counts are bounded by memory, file descriptors and CPU for TLS and heartbeats, and a second bound you choose: how many users one server failure may disconnect at once. An estimate for 10 million concurrent connections:

ws-capacity.mjs
// Assumptions: a chat app at peak. Measure the per-connection numbers on your own stack; these are placeholders.
const peakConnections = 10_000_000;
const memPerConnKB = 64;          // socket buffers + TLS state + app session (rule of thumb, varies 10-100+ KB)
const usableMemGB = 48;           // of a 64 GB box, leaving room for the runtime and spikes
const targetPerServer = 200_000;  // chosen cap: what one failure is allowed to disconnect
const msgsPerConnPerMin = 2;      // inbound messages per connection at peak
const reconnectWindowSec = 30;    // clients reconnect with jitter spread over this window

const byMemory = Math.floor((usableMemGB * 1024 * 1024) / memPerConnKB);
const perServer = Math.min(byMemory, targetPerServer);
const servers = Math.ceil(peakConnections / perServer);
const withHeadroom = Math.ceil(servers * 1.3); // survive losing a zone (+30%)
const inboundPerServer = (perServer * msgsPerConnPerMin) / 60;

console.log(`memory allows       ${byMemory.toLocaleString("en-US")} connections per server`);
console.log(`we run              ${perServer.toLocaleString("en-US")} per server → ${servers} servers, ${withHeadroom} with headroom`);
console.log(`inbound per server  ${Math.round(inboundPerServer).toLocaleString("en-US")} messages/s`);
console.log(`one server dies     ${perServer.toLocaleString("en-US")} clients reconnect`);
console.log(`  all at once       ${perServer.toLocaleString("en-US")} TLS handshakes in the first second`);
console.log(`  jittered over ${reconnectWindowSec}s ${Math.round(perServer / reconnectWindowSec).toLocaleString("en-US")} new connections/s, spread over ${withHeadroom - 1} servers = ${Math.round(perServer / reconnectWindowSec / (withHeadroom - 1))}/s each`);
console.log(`deploy (rolling)    drain ${perServer.toLocaleString("en-US")} sockets per server; at 10% of the fleet per step: ${Math.round(withHeadroom * 0.1)} servers × ${perServer.toLocaleString("en-US")} = ${(Math.round(withHeadroom * 0.1) * perServer).toLocaleString("en-US")} reconnects`);
Result
memory allows       786,432 connections per server
we run              200,000 per server → 50 servers, 65 with headroom
inbound per server  6,667 messages/s
one server dies     200,000 clients reconnect
  all at once       200,000 TLS handshakes in the first second
  jittered over 30s 6,667 new connections/s, spread over 64 servers = 104/s each
deploy (rolling)    drain 200,000 sockets per server; at 10% of the fleet per step: 7 servers × 200,000 = 1,400,000 reconnects
What’s happening
  1. Memory alone would allow about 786,000 connections on a box with 48 GB usable at 64 KB each. The 64 KB is a placeholder, not a fact: it depends on the runtime, TLS library, buffer sizes and how much per-user state the gateway keeps, so the first thing to do in a real project is measure it under load.
  2. We deliberately run 200,000 per server, not 786,000. The cap is a blast-radius decision: it's how many people one crash, one bad deploy or one kernel panic disconnects at once. That gives 50 servers, and 65 with 30% headroom so losing an availability zone doesn't overload the rest.
  3. Each server also handles about 6,667 inbound messages per second (200,000 users × 2 a minute), forwarding them to the chat service. That's ordinary request load, but it's per server, so it bounds CPU together with TLS and heartbeats.
  4. The reconnect storm is the real design constraint. When a server dies, its 200,000 clients all notice within a heartbeat and reconnect. If they all retry immediately, that's 200,000 TLS handshakes in about a second, on top of re-authentication and re-subscription for each one. With random jitter over 30 s, it's 6,667 new connections per second, about 104 per surviving server: easily absorbed.
  5. Deploys are planned reconnect storms. Rolling 10% of the fleet at a time disconnects 1.4 million clients per step, so gateways drain gracefully (stop accepting, tell clients to reconnect elsewhere, wait) and the steps are spaced out. This is also why connection servers are kept thin: if they contain no business logic, they rarely need deploying.
AdvancedRouting, presence and stickiness
  • Load balancing the connections: the handshake is HTTP, so an L7 load balancer can route it (it must pass Upgrade through); after that the connection is pinned to its server for life. Balance by least connections rather than round robin, or new servers sit empty while old ones stay full.
  • Sticky sessions matter only for multi-request transports. Socket.IO's documentation (checked October 2026) says sticky sessions are required when HTTP long-polling is enabled, because every poll must reach the process holding the session, and are no longer needed with WebSocket-only transport. It calls the cross-server component an "adapter" (Redis, Redis Streams, Postgres, and others).
  • Direct messages to one user can use a per-user channel (user:42) that the user's gateway subscribes to on connect, or a connection registry (user → gateway) the sender looks up. The channel is simpler; the registry avoids subscription churn for millions of users.
  • Presence ("online", "last seen") is a heartbeat with a TTL: the gateway refreshes presence:{user} every 30 s with a 60–90 s expiry, so a crashed gateway's users go offline on their own. Broadcasting every presence change to every friend is expensive; fetch presence lazily when a conversation opens, and batch changes.
  • Delivery guarantees: Redis pub/sub is at-most-once (its documentation says a message a subscriber can't handle is "forever lost"). So the order is always persist first, then publish, and every message carries a per-conversation sequence number. A client that reconnects says "I have up to 1,042 in room 7" and fetches the gap from the message store; the backplane is only the fast path. Redis 7 added sharded pub/sub (SSUBSCRIBE/SPUBLISH), which keeps a channel's traffic within one shard of a cluster, so the backplane can scale out with more shards.
  • Slow consumers: a client on a bad network can't keep up with a busy room. Give every socket a bounded send buffer; when it fills, drop non-essential messages (typing indicators) or disconnect it and let it resync, rather than letting one slow phone grow the server's memory.
AdvancedWhat real systems report
  • Slack (Slack Engineering, "Real-time Messaging", April 2023): stateful channel servers each own a set of channels assigned by consistent hashing, about 16 million channels per host at peak; gateway servers hold users' WebSocket connections in several regions and subscribe to the channel servers for their users' channels; a replacement channel server takes under 20 seconds to serve traffic; messages are delivered across the world in about 500 ms.
  • Phoenix (Phoenix Framework blog, November 2015): 2 million WebSocket connections on one 40-core, 128 GB server, limited by ulimit; broadcasting to 1.3 million subscribers first took over 5 s, and after sharding the pub/sub they kept 1 s broadcasts at 2 million. Holding idle connections is cheap; fan-out is where the work is.
  • AWS API Gateway WebSocket APIs (quotas page, checked October 2026), as an example of managed limits: 500 new connections per second per account per region by default, connections last at most 2 hours, idle connections close after 10 minutes, messages up to 128 KB in 32 KB frames. A managed gateway still needs reconnection logic, because every connection ends at two hours.
Walk the message

Alice (on gateway A) sends "hi" to a group with Bob (gateway B) and Chen (gateway C). Gateway B crashes just after the message is published. Does Bob get it, and how?

Show answer

Yes, but not through the backplane: through the catch-up path when he reconnects.

What’s happening
  1. Gateway A receives the frame and calls the chat service, which checks membership, assigns the next sequence number for the room (say 1,043), writes the message to the store and only then publishes it on room:7.
  2. The backplane delivers to gateways A, B and C, which subscribed because they hold members of room 7. C pushes to Chen. A pushes to Alice as the acknowledgement that carries the sequence number. B crashed, so its copy is lost: pub/sub is at-most-once and doesn't keep messages for absent subscribers.
  3. Bob's client notices the missing heartbeat, waits a random backoff, and reconnects; the load balancer puts him on another gateway, say D. D authenticates him and subscribes to room:7.
  4. Bob's client sends the last sequence number it has for room 7, 1,042. The chat service reads everything after 1,042 from the store, which includes "hi" (1,043), and D pushes it. If a newer message arrived meanwhile, it comes live and the client orders messages by sequence number and drops duplicates.
  5. This is why the message is persisted before publishing: if it were published first and the write failed, Chen would have seen a message that doesn't exist for anyone who reconnects.

#Webhooks

Added

A webhook is an API call in reverse: instead of your system polling a provider ("has the payment gone through yet?"), the provider sends an HTTP POST to a URL you registered, the moment something happens (payment_intent.succeeded, push, invoice.paid). It's how Stripe, GitHub, Shopify and Twilio tell other companies' systems about events, and it's the server-to-server cousin of the real-time techniques above.

Because the request arrives from the internet at a public URL, anyone can send one, and because the network is unreliable, the provider retries. So every webhook design rests on three properties. Authenticity: the receiver can prove the request came from the provider and wasn't changed (an HMAC signature over the body). Freshness: an old, captured request can't be replayed (a signed timestamp with a tolerance). Idempotency: delivering the same event twice, or out of order, does no harm (de-duplicate on the event id, compare versions).

A mental model: a webhook is a registered letter. The seal (signature) proves who sent it and that nobody opened it; the postmark (timestamp) shows it's not an old letter dropped in your box again; the tracking number (event id) means a second copy of the same letter goes straight into the "already handled" tray. And you sign for the letter at the door (return 2xx quickly) before you sit down to act on it (process it from your own queue).

PROVIDER RECEIVER (YOU) Event e.g. invoice paid Event log id · type · body Queue per endpoint Delivery workers Endpoint verify · dedupe → 2xx fast signed POST non-2xx or timeout: retry with backoff Give up flag · disable ~3 days Your queue worker applies it enqueue
The provider retries until it gets a 2xx; the receiver acknowledges fast and does the real work from its own queue.

The signing scheme below follows Stripe's documented design (checked October 2026): the header carries a timestamp t and an HMAC-SHA256 signature v1 over the string "{t}.{raw body}", and the libraries reject timestamps more than 5 minutes off. It's run against a genuine request and five attacks or mistakes:

webhook-hmac.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = "whsec_demo_only"; // shared once, out of band, per receiving endpoint
const TOLERANCE_SEC = 300;        // 5 minutes, the default in Stripe's libraries

// Sender: sign "timestamp.rawBody" and send both in one header.
function sign(rawBody, timestamp, secret = SECRET) {
  const mac = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return `t=${timestamp},v1=${mac}`;
}

// Receiver: recompute over the exact bytes received, compare in constant time, then check freshness.
function verify(rawBody, header, nowSec) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const expected = Buffer.from(createHmac("sha256", SECRET).update(`${parts.t}.${rawBody}`).digest("hex"));
  const given = Buffer.from(parts.v1 ?? "");
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) return "reject: bad signature";
  const age = nowSec - Number(parts.t);
  if (Math.abs(age) > TOLERANCE_SEC) return `reject: too old (${age} s)`;
  return "accept";
}

const body = '{"id":"evt_1","type":"payment.succeeded","amount":5000}';
const sentAt = 1_790_000_000; // a fixed clock, in seconds
const header = sign(body, sentAt);
console.log("header:", header.slice(0, 32) + "…");

const cases = [
  ["genuine, 2 s later", body, header, sentAt + 2],
  ["amount changed in transit", body.replace("5000", "500000"), header, sentAt + 2],
  ["body re-serialised by a JSON parser", JSON.stringify(JSON.parse(body), null, 1), header, sentAt + 2],
  ["captured and replayed 1 hour later", body, header, sentAt + 3600],
  ["replayed with a fresh t= but the old v1", body, header.replace(`t=${sentAt}`, `t=${sentAt + 3600}`), sentAt + 3600],
  ["signed with someone else's secret", body, sign(body, sentAt, "whsec_attacker"), sentAt + 2],
];
for (const [label, raw, h, now] of cases) console.log(`${label.padEnd(40)} → ${verify(raw, h, now)}`);
Result
header: t=1790000000,v1=682b97a5f32fb39f…
genuine, 2 s later                       → accept
amount changed in transit                → reject: bad signature
body re-serialised by a JSON parser      → reject: bad signature
captured and replayed 1 hour later       → reject: too old (3600 s)
replayed with a fresh t= but the old v1  → reject: bad signature
signed with someone else's secret        → reject: bad signature
What’s happening
  1. Signing: the sender computes HMAC-SHA256(secret, "1790000000.{body}") and sends t=1790000000,v1=682b97a5… in a header. An HMAC is a keyed hash: without the secret you can't produce a matching value, and changing a single byte of the input changes the output completely. The secret was shared once when the endpoint was registered and never travels with requests.
  2. The genuine request arrives 2 s later. The receiver recomputes the HMAC over the timestamp it was given and the exact raw bytes it received, compares it with v1 using timingSafeEqual (a plain === returns faster on an early mismatch, which leaks how many leading characters were right), then checks the age: 2 s is within 300, so accept.
  3. Tampering: changing 5000 to 500000 changes the HMAC, so the signature check fails. Re-serialising fails too, even though the JSON means the same thing: JSON.parse then JSON.stringify with indentation produces different bytes. That's the most common real-world bug: a framework parses the body before your handler sees it, and you verify the re-stringified version. Verify the raw body first, then parse.
  4. Replay: an attacker who captured a genuine request can resend it byte for byte; the signature is still valid, but the timestamp is an hour old, so the freshness check rejects it. Changing t= to look fresh doesn't help, because t is inside the signed string: the HMAC no longer matches. That's why the timestamp is signed rather than just sent alongside.
  5. Wrong secret: a request signed with any other key fails. Within the 5-minute window a replay would still pass, which is why the receiver also de-duplicates on the event id (next model). A tolerance of 0 doesn't mean "strict": Stripe's docs warn that it disables the recency check entirely.
AdvancedRetries, duplicates and ordering on the receiver

Providers deliver at least once and in no guaranteed order: Stripe's documentation says it retries live-mode events for up to three days with exponential backoff, that endpoints may occasionally receive the same event more than once, and that it doesn't guarantee events arrive in the order they were generated. Here's a receiver that's down for 40 minutes, with one acknowledgement lost, using the retry gaps from the Standard Webhooks specification's example schedule:

webhook-delivery.mjs
// Sender: at-least-once delivery with the retry gaps from the Standard Webhooks spec's example schedule.
const GAPS = [0, 5, 300, 1800, 7200, 18000, 36000, 50400, 72000, 86400]; // seconds before each attempt
const at = (sec) => `${String(Math.floor(sec / 3600)).padStart(2)}h${String(Math.floor((sec % 3600) / 60)).padStart(2, "0")}m${String(sec % 60).padStart(2, "0")}s`;

// Receiver: returns 2xx fast, de-duplicates on event id, ignores events older than what it already applied.
const processed = new Set();
const subscription = { status: "trialing", version: 0 };
function receive(event) {
  if (processed.has(event.id)) return { status: 200, note: "duplicate, skipped" };
  processed.add(event.id);
  if (event.version <= subscription.version) return { status: 200, note: `stale v${event.version}, ignored` };
  Object.assign(subscription, { status: event.status, version: event.version });
  return { status: 200, note: `applied → ${event.status}` };
}

// The receiver is down for the first 40 minutes. One reply (evt_b, attempt 1) is lost after it was processed.
const downUntil = 2400;
const lostReplies = new Set(["evt_b#1"]);
// A tiny discrete-event loop: every pending attempt has a time; always run the earliest one next.
const queue = [
  { t: 1000, attempt: 1, event: { id: "evt_a", status: "past_due", version: 1 } },
  { t: 2500, attempt: 1, event: { id: "evt_b", status: "active", version: 2 } }, // created later
];
while (queue.length) {
  queue.sort((x, y) => x.t - y.t);
  const { t, attempt, event } = queue.shift();
  const retry = () => attempt < GAPS.length
    ? queue.push({ t: t + GAPS[attempt], attempt: attempt + 1, event })
    : console.log(`${event.id}: gave up, endpoint flagged`);
  if (t < downUntil) { console.log(`${at(t)}  ${event.id} attempt ${attempt}: connection refused`); retry(); continue; }
  const reply = receive(event);
  if (lostReplies.delete(`${event.id}#${attempt}`)) {
    console.log(`${at(t)}  ${event.id} attempt ${attempt}: ${reply.note}, but the 200 is lost (sender times out)`);
    retry();
    continue;
  }
  console.log(`${at(t)}  ${event.id} attempt ${attempt}: ${reply.status} ${reply.note}`);
}
const total = GAPS.reduce((a, b) => a + b, 0);
console.log("final:", JSON.stringify(subscription));
console.log("whole schedule spans", at(total).trim(), "≈", (total / 86400).toFixed(1), "days");
Result
 0h16m40s  evt_a attempt 1: connection refused
 0h16m45s  evt_a attempt 2: connection refused
 0h21m45s  evt_a attempt 3: connection refused
 0h41m40s  evt_b attempt 1: applied → active, but the 200 is lost (sender times out)
 0h41m45s  evt_b attempt 2: 200 duplicate, skipped
 0h51m45s  evt_a attempt 4: 200 stale v1, ignored
final: {"status":"active","version":2}
whole schedule spans 75h35m05s ≈ 3.1 days
What’s happening
  1. Two events about one subscription: evt_a ("past_due", version 1) happens at 16m40s, evt_b ("active", version 2) at 41m40s. The receiver is down until 40m00s. The loop always runs the earliest pending attempt, like a real scheduler.
  2. evt_a fails at 16m40s, then 5 s later, then 5 minutes later; each failure schedules the next attempt using the next gap, so the waits grow: 0 s, 5 s, 5 min, 30 min…
  3. At 41m40s the receiver is back, and evt_b arrives first even though it was generated after evt_a, because evt_a is still waiting out its 30-minute gap. The receiver applies it ("active") but the 200 is lost, so the sender, seeing a timeout, retries 5 s later. The receiver finds evt_b in processed and answers 200 duplicate, skipped, without applying it twice.
  4. At 51m45s the old evt_a finally lands. Applying it blindly would move the subscription back to "past_due", a customer wrongly locked out. The receiver compares versions: 1 <= 2, so it acknowledges with 200 (it was delivered fine; there's nothing to retry) and ignores it. Final state: active, version 2.
  5. The full schedule spans 75h35m, about 3.1 days, in line with Stripe's three days. A receiver that's down for a weekend loses events unless it can catch up some other way, which is why providers also offer an events list API (Stripe's lists the last 30 days, with a filter for failed deliveries).
  6. The two receiver rules shown are the whole contract: de-duplicate on the event id (store processed ids, with a TTL longer than the retry window), and never let an older event overwrite newer state (compare a version, a sequence number or the object's updated time; or ignore the payload and fetch the object's current state from the provider's API).
AdvancedWhat the big providers do
StripeGitHubStandard Webhooks
Signature headerStripe-Signature: t=…,v1=…X-Hub-Signature-256: sha256=…webhook-id, webhook-timestamp, webhook-signature: v1,<base64>
What's signed{t}.{raw body}, HMAC-SHA256raw body, HMAC-SHA256 (hex){msg_id}.{timestamp}.{payload}
Replay protectionsigned timestamp; libraries default to 5 mindelivery id X-GitHub-Delivery (same value on a redelivery)signed timestamp, "allowable tolerance"
Retriesup to 3 days, exponential backoff (live mode)redeliver from the UI or APImulti-day schedule with backoff and jitter
Response expected2xx quickly; redirects count as failures2xx within 10 seconds2xx; request timeout 15–30 s recommended

Sources, all checked October 2026: Stripe's webhook documentation, GitHub's "Validating webhook deliveries" and "Best practices for using webhooks", and the Standard Webhooks specification (adopted by, among others, OpenAI, Twilio, Kong and Svix). Stripe also signs with two secrets at once while one is being rolled (up to 24 hours), so receivers can switch keys without missing events: accept a request if any of the v1 signatures matches.

AdvancedIf you're the provider

When the interview is "design a webhook system", the receiver rules above become your documentation, and the sending side is the design:

  • Write the event to a log first (the outbox pattern), then deliver from it. A webhook must never be "sent from inside the request" that changed the data, or a crash loses it.
  • One queue per endpoint, or at least per customer, with a concurrency cap: one customer's slow or dead endpoint must not hold up deliveries to everyone else.
  • Retries with exponential backoff and jitter over days, then mark the endpoint failing, email the owner, and eventually disable it; keep the events available through an API so they can catch up.
  • Short timeouts (a few seconds to 30 s) and treat redirects as failures.
  • Guard against SSRF: customers choose the URL, so resolve it and refuse private and link-local addresses (10.0.0.0/8, 169.254.169.254, localhost) and re-check on every delivery, because DNS can change. Send from published egress IPs so customers can allowlist them.
  • Thin events are an option: send only { id, type, objectId } and let the receiver fetch the current object. Payloads stay small, nothing sensitive leaks into logs, and ordering stops mattering because the receiver always reads the latest state. Stripe offers this as "thin" events next to full "snapshot" events.
AdvancedVerify before you parse

#Rate limiting algorithms

Added

A rate limiter decides, for every request, "has this caller used up its allowance?", and rejects the request (429 Too Many Requests) if so. It protects the backend from overload and abuse, keeps one noisy customer from starving the rest, and enforces paid tiers. The policy is written as "N requests per window per key", where the key is whatever you're limiting: an API key, a user, an IP address, an endpoint, or a combination.

The algorithms differ in how they answer "used up its allowance recently", and that changes what traffic gets through:

  • Fixed window: a counter per aligned interval (12:00:00–12:00:10), reset at each boundary. Think of a daily quota that resets at midnight.
  • Sliding window log: remember the time of every accepted request and count the ones inside the last window. A bouncer with a list of exact entry times.
  • Sliding window counter: keep this window's and the previous window's counts, and estimate the moving count by weighting the previous one by how much of it still overlaps.
  • Token bucket: a bucket holds up to capacity tokens and refills at a steady rate; each request takes a token. An arcade token machine that drips coins in and lets you save up a handful.
  • Leaky bucket: requests wait in a queue that drains at a fixed rate; when the queue is full, new ones are rejected. A funnel: pour fast, it still comes out at the same speed.

The Node.js course implements these in Express and Redis. The design-level question is what each lets through, so here are four of them on the same seeded trace: background traffic at about one request every two seconds, plus a burst of 20 requests straddling the 10-second boundary, against "10 requests per 10 seconds":

limiters.mjs
// Policy: "10 requests per 10 seconds" (a sustained rate of 1/s). One client, one seeded trace.
const LIMIT = 10, WINDOW = 10_000;
function mulberry32(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(2026);
const trace = [];
for (let t = 0; t < 30_000; t += 250) if (rand() < 0.12) trace.push(t);       // background, about 0.5/s
for (let i = 0; i < 10; i++) trace.push(9_500 + i * 50, 10_000 + i * 50);      // a burst either side of t = 10 s
trace.sort((a, b) => a - b);

const limiters = {
  "fixed window": () => { const n = new Map(); return (t) => { const w = Math.floor(t / WINDOW); const c = (n.get(w) ?? 0) + 1; n.set(w, c); return c <= LIMIT; }; },
  "sliding log": () => { const log = []; return (t) => { while (log.length && log[0] <= t - WINDOW) log.shift(); if (log.length >= LIMIT) return false; log.push(t); return true; }; },
  "sliding counter": () => {
    const n = new Map();
    return (t) => {
      const w = Math.floor(t / WINDOW);
      const est = (n.get(w - 1) ?? 0) * (1 - (t % WINDOW) / WINDOW) + (n.get(w) ?? 0);
      if (est >= LIMIT) return false;
      n.set(w, (n.get(w) ?? 0) + 1);
      return true;
    };
  },
  "token bucket": () => {
    let credit = LIMIT * 1000, last = 0; // capacity 10 tokens, refill 1 per second; counted in ms so it stays exact
    return (t) => { credit = Math.min(LIMIT * 1000, credit + (t - last)); last = t; if (credit < 1000) return false; credit -= 1000; return true; };
  },
};

// The number that matters to the backend: the most requests it ever saw in any 10-second span.
const worstSpan = (ts) => Math.max(...ts.map((s) => ts.filter((t) => t >= s && t < s + WINDOW).length));
const inBurst = (ts) => ts.filter((t) => t >= 9_500 && t < 10_500).length;

console.log(`trace: ${trace.length} requests in 30 s, 20 of them between 9.5 s and 10.5 s`);
console.log("algorithm         accepted  rejected  burst accepted  worst 10 s span");
for (const [name, make] of Object.entries(limiters)) {
  const allow = make();
  const ok = trace.filter((t) => allow(t));
  console.log(`${name.padEnd(17)} ${String(ok.length).padStart(8)}  ${String(trace.length - ok.length).padStart(8)}  ${String(inBurst(ok)).padStart(14)}  ${String(worstSpan(ok)).padStart(15)}`);
}
Result
trace: 35 requests in 30 s, 20 of them between 9.5 s and 10.5 s
algorithm         accepted  rejected  burst accepted  worst 10 s span
fixed window            25        10              14               20
sliding log             18        17               4               10
sliding counter         19        16               5               11
token bucket            24        11               9               16
What’s happening
  1. The seeded trace has 35 requests: 6 background requests before the burst (at 2.25 s, 2.5 s, 3.25 s, 4.75 s, 8.5 s and 8.75 s), 10 at 9.50–9.95 s, 10 at 10.00–10.45 s, then 9 more background requests up to 27.75 s. The last column is what the backend actually experiences: the largest number of accepted requests in any 10-second span, measured from every accepted request's timestamp.
  2. Fixed window: window 0 (0–10 s) already holds 6 background requests, so 4 burst requests at 9.50–9.65 s fill it to 10 and the rest of the first half is rejected. At 10.000 s a new window starts at zero, and all 10 second-half requests are accepted. Between 2.25 s and 12.25 s the backend took 20 requests, twice the policy. That's the boundary burst: a client (or a botnet) that times its traffic around the reset gets 2× the limit, and the window then rejects background traffic later (11.0 s to 19.25 s) for the same reason.
  3. Sliding log is exact: at 9.70 s it counts the 10 accepted timestamps in the last 10 s and rejects; it keeps rejecting until old timestamps age out (the first expires at 12.25 s). Worst span 10, exactly the policy, but it stored up to 10 timestamps per client to know it.
  4. Sliding counter estimates previous × (share of the previous window still in range) + current. At 10.050 s that's 10 × 0.995 + 0 = 9.95, just under 10, so one request slips in; at 10.100 s it's 10 × 0.99 + 1 = 10.9 and the rest are rejected. Worst span 11: one over, because it assumes the previous window's requests were spread evenly when they were bunched at its end. With two counters per client instead of a list, that's the usual production choice; Cloudflare reported in 2017 that across 400 million requests from 270,000 sources, only 0.003% were wrongly allowed or limited with this approximation.
  5. Token bucket (capacity 10, refill 1/s) started full and refilled during the quiet seconds, so it held 9 tokens at 9.5 s and accepted 9 of the burst, then rejected until tokens dripped back. Its worst span is 16: a bucket guarantees at most capacity + rate × T requests in any span T, which here is 10 + 1 × 10 = 20, not 10. That isn't a bug; it's the point of the bucket: allow a controlled burst, enforce the average.
  6. The tokens are counted in milliseconds of credit (integers) rather than fractional tokens. With tokens += elapsed / 1000, floating-point rounding can leave a bucket at 0.9999999 when it should hold exactly 1 and reject a request that should pass; integer units avoid that.
AdvancedPolicing vs shaping: the leaky bucket

A token bucket polices: a request either passes now or is rejected. A leaky bucket used as a queue shapes: requests wait and leave at a fixed pace, which is what you want in front of something that can only take a steady rate (a partner API, an SMS gateway, a database batch writer). The same 12-request burst through both:

leaky-bucket.mjs
// Leaky bucket as a queue: requests wait in a bucket of 5 and leave at a steady 1 per second.
// The same 12-request burst (t = 0, 100, ..., 1100 ms) goes through it and through a token bucket.
const burst = Array.from({ length: 12 }, (_, i) => i * 100);

function leakyQueue(arrivals, { size, drainMs }) {
  let nextFree = 0;          // when the "pipe" can release the next request
  const waiting = [];        // release times of requests still in the bucket
  return arrivals.map((t) => {
    while (waiting.length && waiting[0] <= t) waiting.shift(); // requests already released
    if (waiting.length >= size) return { t, result: "rejected (bucket full)" };
    const releaseAt = Math.max(t, nextFree);
    nextFree = releaseAt + drainMs;
    waiting.push(releaseAt);
    return { t, result: `sent at ${releaseAt} ms (waited ${releaseAt - t} ms)` };
  });
}

function tokenBucket(arrivals, { capacity, refillMs }) {
  let credit = capacity * refillMs, last = 0; // tokens counted in ms of refill time: integers, no rounding drift
  return arrivals.map((t) => {
    credit = Math.min(capacity * refillMs, credit + (t - last));
    last = t;
    if (credit < refillMs) return { t, result: "rejected (no token)" };
    credit -= refillMs;
    return { t, result: "sent at once" };
  });
}

const leaky = leakyQueue(burst, { size: 5, drainMs: 1000 });
const token = tokenBucket(burst, { capacity: 5, refillMs: 1000 });
for (let i = 0; i < burst.length; i++) {
  console.log(`t=${String(burst[i]).padStart(4)}  leaky: ${leaky[i].result.padEnd(32)} token: ${token[i].result}`);
}
Result
t=   0  leaky: sent at 0 ms (waited 0 ms)       token: sent at once
t= 100  leaky: sent at 1000 ms (waited 900 ms)  token: sent at once
t= 200  leaky: sent at 2000 ms (waited 1800 ms) token: sent at once
t= 300  leaky: sent at 3000 ms (waited 2700 ms) token: sent at once
t= 400  leaky: sent at 4000 ms (waited 3600 ms) token: sent at once
t= 500  leaky: sent at 5000 ms (waited 4500 ms) token: rejected (no token)
t= 600  leaky: rejected (bucket full)           token: rejected (no token)
t= 700  leaky: rejected (bucket full)           token: rejected (no token)
t= 800  leaky: rejected (bucket full)           token: rejected (no token)
t= 900  leaky: rejected (bucket full)           token: rejected (no token)
t=1000  leaky: sent at 6000 ms (waited 5000 ms) token: sent at once
t=1100  leaky: rejected (bucket full)           token: rejected (no token)
What’s happening
  1. Leaky bucket: the first request goes straight out at 0 ms; each later one is scheduled drainMs after the previous release (1000, 2000, … ms). Up to 5 can be waiting; the 6th arrival (500 ms) takes the last place, and the 7th to 10th (600–900 ms) find the bucket full and are rejected. At 1000 ms the request scheduled for 1000 ms leaves, freeing a place, so the arrival at 1000 ms is accepted for 6000 ms. Downstream sees exactly one request per second, never a burst; the cost is latency, up to 5 s of waiting.
  2. Token bucket (capacity 5, 1 token per second): the first 5 go out immediately, which is what a user clicking around wants, then it rejects until a token refills. At 1000 ms exactly one token has dripped back, so that request passes. Downstream sees a burst of 5, then 1 per second.
  3. Same long-run rate, different shape: a token bucket protects you from clients while letting them burst; a leaky queue protects a fragile downstream by smoothing. Use the bucket at your API's front door, the queue (or a job queue with a fixed worker rate) when you're the client of someone else's limit.
  4. Stripe's own advice for clients of its API (checked October 2026) is exactly this: control your outbound traffic with a client-side token bucket, and back off exponentially with jitter when you get 429.
AdvancedRunning it across many servers

A limiter on one server only sees that server's traffic. With 20 instances behind a load balancer you need a shared view, and the design choices are:

  • Where it runs: at the edge or API gateway for coarse per-IP and per-key limits (cheapest place to drop traffic), and in the service for fine-grained limits (per user per endpoint, cost-based).
  • Shared counters in a fast store, usually Redis, keyed rl:{key}:{window}. The check-and-increment must be one atomic step (a single INCR with an expiry, or a Lua script for the token bucket), or two instances read the same count and both let a request through.
  • Local plus global: each instance enforces limit / instances locally with no network call, or keeps a local token bucket that it refills from the global store in batches. Slightly inaccurate, much faster, and it keeps working when Redis is slow.
  • Fail open or closed: if the limiter store is down, do you allow everything (availability) or reject everything (protection)? Most public APIs fail open for normal limits and closed for expensive or security-sensitive ones (login attempts, SMS sending).
  • Several limits at once: per second (burst) and per day (quota), per key and per endpoint, plus a concurrency limit for expensive calls.

The memory cost per key decides which algorithm is affordable at scale. For 10 million active API keys at 100 requests per minute:

limiter-memory.mjs
// Assumptions: 10 million active API keys, each limited to 100 requests per minute.
const keys = 10_000_000;
const limit = 100;
const keyBytes = 40;          // the Redis key name, e.g. "rl:{api_key}:{window}"
const perEntryOverhead = 64;  // rough per-key bookkeeping in a store like Redis (rule of thumb)
const timestampBytes = 8;
const sortedSetPerItem = 24;  // extra per member in a sorted set, on top of the timestamp (rule of thumb)

const designs = {
  "fixed window (1 counter)": keyBytes + perEntryOverhead + 8,
  "sliding counter (2 counters)": 2 * (keyBytes + perEntryOverhead + 8),
  "token bucket (tokens + time)": keyBytes + perEntryOverhead + 2 * 8,
  "sliding log (up to 100 times)": keyBytes + perEntryOverhead + limit * (timestampBytes + sortedSetPerItem),
};
for (const [name, bytesPerKey] of Object.entries(designs)) {
  const gb = (keys * bytesPerKey) / 1024 ** 3;
  console.log(`${name.padEnd(30)} ${String(bytesPerKey).padStart(5)} B/key  → ${gb.toFixed(1).padStart(5)} GB`);
}
Result
fixed window (1 counter)         112 B/key  →   1.0 GB
sliding counter (2 counters)     224 B/key  →   2.1 GB
token bucket (tokens + time)     120 B/key  →   1.1 GB
sliding log (up to 100 times)   3304 B/key  →  30.8 GB
What’s happening
  1. The per-key overhead and per-member figures are rules of thumb for a Redis-like store, not measured values; the point is the ratio. Every design pays the key name and bookkeeping once (104 bytes here).
  2. Counter-based designs add 8–16 bytes of state: about 1–2 GB for 10 million keys, which fits in one Redis node with room to spare.
  3. The sliding log stores up to 100 timestamps per key in a sorted set: about 3.3 KB per key and 31 GB in total, so a cluster just for rate limiting, and every check is a range delete plus a count instead of one increment.
  4. That's why the sliding log is kept for low limits where exactness matters (5 login attempts per 15 minutes), and the sliding counter or token bucket handle general API traffic.
AdvancedTelling clients about limits
  • Reject with 429 Too Many Requests and a Retry-After header (seconds, or an HTTP date).
  • Tell well-behaved clients how close they are. GitHub's REST API (checked October 2026) sends x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-used, x-ratelimit-reset (epoch seconds) and x-ratelimit-resource, allows 5,000 requests per hour per authenticated user (60 per hour unauthenticated, per IP), and answers 403 or 429 when the limit is exceeded. The IETF's RateLimit and RateLimit-Policy header fields are still an Internet-Draft (draft-ietf-httpapi-ratelimit-headers-11, May 2026), so name them as "the emerging standard".
  • Real limits to quote: Stripe allows 100 requests per second per account in live mode (25 in a sandbox, and 25 per endpoint unless noted) plus a concurrency limit of 50 in-progress requests, and adds a Stripe-Rate-Limited-Reason header to its 429s. AWS API Gateway's default account throttle is 10,000 requests per second per region with a burst of 5,000, explicitly a token bucket. (Both checked October 2026.)
AlgorithmState per keyBursts let throughAccuracyTypical use
Fixed window1 counterup to 2× the limit at a boundaryapproximatesimple quotas, daily limits
Sliding logup to N timestampsnoneexactlow limits: logins, OTPs
Sliding counter2 countersslightly over the limitnear exactgeneral API limits at scale
Token buckettokens + last refill timeup to capacity, by designexact for its modelAPIs that allow short bursts (AWS, Stripe)
Leaky bucket (queue)a queuenone: smoothed into delayexact output rateshaping calls to a fragile downstream

The full case study, with the distributed design and failure modes, is Design a rate limiter.

How many requests can a fixed window let through?

The policy is 100 requests per minute, enforced with a fixed window aligned to the minute. What's the most requests a client can get accepted in any 60-second span, and how?

Show answer

200. Send 100 requests in the last moments of one minute (say 12:00:59.0–12:00:59.9) and 100 more in the first moments of the next (12:01:00.0–12:01:00.9).

What’s happening
  1. All of the first 100 land in the 12:00 window, whose counter goes 0 → 100; every one passes because the counter never exceeds 100.
  2. At 12:01:00.000 the window key changes to 12:01 and its counter starts at 0, regardless of what happened a second ago.
  3. The next 100 pass too. In the two seconds from 12:00:59 to 12:01:01, the backend accepted 200 requests: 2× the policy, which the model above showed as a worst span of 20 for a limit of 10.
  4. A sliding log would accept exactly 100 in any 60-second span. A token bucket with capacity 100 refilling 100 per minute would accept about 103 in those two seconds (100 saved up plus 3.3 refilled) and at most 200 over a full 60-second span: the capacity + rate × T bound again.

#Idempotency keys for APIs

Added

A client sends POST /payments, and the connection drops before the response arrives. Did the payment happen? The client can't know: the request may have died on the way in, or the server may have charged the card and lost the reply on the way out. Retrying risks a double charge; not retrying risks a lost order. An idempotency key removes the dilemma: the client generates a unique key for each logical operation (one per "Pay" click, not per attempt), sends it in an Idempotency-Key header, and sends the same key on every retry. The server records the outcome of the first request under that key and answers any repeat with the stored outcome instead of doing the work again.

The mental model is a cloakroom ticket. The first time you hand over a coat, you get ticket 7f3a. If you come back and show 7f3a again, you get the same coat back, not a second coat; if you show 7f3a while the attendant is still hanging your coat up, you're told to wait a moment; and if you show 7f3a but describe a different coat, something's wrong and the attendant refuses.

This turns an unsafe POST into one that's safe to retry, which is what lets SDKs, mobile apps and queues retry payments automatically. It's the API-level form of the general rule in Idempotency and exactly-once: at-least-once delivery plus an idempotent receiver.

POST /payments key: k-7f3a Look up key + body fingerprint expired (e.g. 24 h) counts as new Claim the key state: in_progress Charge, then save 201 + body · done 409 Conflict first one still running Replay response same 201, no charge 422 same key, other body new running same body other body
Four outcomes for one key. Only the "new" branch does any work; every retry ends in a replay, a wait, or a refusal.

Here's the store with a fake clock, run through the five situations that happen in production:

idempotency-keys.mjs
import { createHash } from "node:crypto";

let now = 0;                                  // fake clock, in hours
const TTL_HOURS = 24;
const keys = new Map();                       // key -> { state, fingerprint, response, expiresAt }
const charges = [];                           // what actually hit the card network
const fingerprint = (body) => createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 12);

// POST /payments. `gate` lets the demo pause the charge to create a concurrent duplicate.
async function createPayment(key, body, gate = Promise.resolve()) {
  const fp = fingerprint(body);
  const row = keys.get(key);
  if (row && row.expiresAt <= now) keys.delete(key);  // expired: forget it
  const existing = keys.get(key);
  if (existing) {
    if (existing.fingerprint !== fp) return [422, "key reused with a different body"];
    if (existing.state === "in_progress") return [409, "original request still running, retry later"];
    return [...existing.response, "(replayed)"];
  }
  // Claim the key atomically before doing anything (in a database: INSERT ... unique key; in Redis: SET NX).
  keys.set(key, { state: "in_progress", fingerprint: fp, expiresAt: now + TTL_HOURS });
  await gate;
  const id = `pay_${charges.push({ key, ...body })}`;
  const response = [201, id];
  Object.assign(keys.get(key), { state: "done", response });
  return response;
}

const log = (label, r) => console.log(label.padEnd(44), r.join(" "));

log("1. first attempt (reply lost on the way back)", await createPayment("k-7f3a", { amount: 5000 }));
log("2. client retries with the same key", await createPayment("k-7f3a", { amount: 5000 }));
log("3. same key, amount changed by a bug", await createPayment("k-7f3a", { amount: 9000 }));

let release;
const slow = createPayment("k-91bc", { amount: 1200 }, new Promise((r) => (release = r)));
log("4. double-click while the first is running", await createPayment("k-91bc", { amount: 1200 }));
release();
log("   ...the first one finishes", await slow);

now = 25;
log("5. same key again 25 hours later", await createPayment("k-7f3a", { amount: 5000 }));
console.log("charges made:", charges.map((c) => `${c.key}:${c.amount}`).join(", "));
Result
1. first attempt (reply lost on the way back) 201 pay_1
2. client retries with the same key          201 pay_1 (replayed)
3. same key, amount changed by a bug         422 key reused with a different body
4. double-click while the first is running   409 original request still running, retry later
   ...the first one finishes                 201 pay_2
5. same key again 25 hours later             201 pay_3
charges made: k-7f3a:5000, k-91bc:1200, k-7f3a:5000
What’s happening
  1. First attempt: key k-7f3a isn't in the store, so the server claims it first (in_progress, with a SHA-256 fingerprint of the body and an expiry 24 h out), then charges the card (pay_1) and stores the response 201 pay_1 under the key, state done. In the story the reply is lost, but the store doesn't care: the outcome is recorded.
  2. Retry with the same key: found, same fingerprint, state done, so the server returns the stored 201 pay_1 and does nothing else. The client gets exactly the answer it missed. No second charge.
  3. Same key, different body: a bug (or an attacker) reuses k-7f3a with amount: 9000. The fingerprint differs, so the server refuses with 422 instead of silently returning the 5000 payment as if it matched. That's why the fingerprint is stored: a key identifies one request, not a slot to reuse.
  4. Concurrent duplicate: a double-click sends two requests with k-91bc at once. The first claims the key and pauses mid-charge (the gate promise simulates a slow card network). The second sees in_progress and gets 409: "the first is still running, retry later". When the first finishes, it returns 201 pay_2, and a later retry of the second would get that replayed. Claiming before working is what makes this safe: if the server checked for the key, then did the work, then wrote the key, both requests would pass the check and charge twice.
  5. After expiry: at hour 25 the key is gone, so the same request is treated as new and charges again: pay_3. Keys can't live forever, so the retry horizon must be shorter than the key lifetime: a client must stop retrying a payment well before 24 hours, and anything older goes to reconciliation. The last line shows the three charges that actually happened: two legitimate, and one duplicate caused by retrying past the TTL.
AdvancedWhat the standards and Stripe say
  • Stripe (checked October 2026): keys up to 255 characters, V4 UUIDs suggested; for API v1 it saves the status code and body of the first request after the endpoint starts executing, and replays them, including 500 errors; keys can be pruned once they're at least 24 hours old; incoming parameters are compared with the original and a mismatch is an error; requests that fail validation or that conflict with a concurrently executing request with the same key aren't saved, so they can be retried; all POST requests accept keys, and sending them on GET or DELETE has no effect because those are idempotent already.
  • The IETF draft (draft-ietf-httpapi-idempotency-key-header-08, October 2026, still an Internet-Draft): 400 when a required key is missing, 422 when a key is reused with a different payload, 409 when a request with the same key is still being processed; a fingerprint of the payload is optional; the server should document how long keys are kept.
AdvancedMaking it correct in a real service
  • Store the key in the same transaction as the effect. If the payment row and the idempotency row are in one database, insert both in one transaction with a unique constraint on (account_id, idempotency_key). A crash then leaves either both or neither; there's no "charged but no key" state.
  • When the effect is an external call (a card network, an email provider), you can't put it in your transaction. Pass an idempotency key downstream too (the card processor's own key, derived from yours), so that if you crash after charging and retry, the processor deduplicates. Brandur Leach's widely cited "Implementing Stripe-like Idempotency Keys in Postgres" (2017) splits such a request into atomic phases (local changes committed in one transaction between external calls) and stores a recovery point on the key after each one, so a retried request resumes just before the step that failed; the Stripe call inside it carries its own key derived from the local one.
  • A crashed request leaves the key in_progress forever unless it carries a lease: store locked_at, and let a retry take over after, say, a minute, re-checking downstream state before acting.
  • Scope keys per account or API key, so two customers who happen to generate the same string don't collide, and so a key can't be used to read someone else's stored response.
  • Generate the key once per user intent (when the checkout page is created, or when the "Pay" button is first pressed) and keep it across retries and app restarts. Generating a fresh UUID inside the retry loop gives every attempt a new key and protects nothing.
  • Should errors be replayed? Stripe replays 500s for v1, which keeps the contract simple ("same key, same answer") but means the client needs a new key to try again after a server error. Replaying only 2xx and certain 4xx is the other common choice; either way, document it.
AdvancedCheck-then-act isn't idempotent

#API versioning and backwards compatibility

Added

The moment an API has a client you don't deploy yourself (a mobile app, a partner, a customer's script), you can no longer change it freely. A mobile app version stays installed for years, and a partner's integration was written once by someone who left. So the real rule isn't "version your API" but "never break a client that worked yesterday", and versioning is the tool for the rare change you can't make compatibly.

Most changes don't need a new version at all: they're additive (a new endpoint, a new optional parameter, a new response field), and clients written as tolerant readers (ignore fields you don't know, don't assume a fixed set of enum values) keep working. A change is breaking when an existing, correct client can stop working: removing or renaming a field, changing its type or meaning, making an optional input required, or changing a default.

The mental model is a power socket standard. The utility can add a USB port to new sockets, raise the capacity behind the wall, or offer a new socket type in new buildings, and every old plug still fits. What it can't do is change the shape of the holes in existing sockets. When it truly must, it installs the new socket next to the old one and keeps both for years.

This model classifies the differences between two versions of a contract the way a reviewer should:

breaking-changes.mjs
// Compare two versions of an API contract and classify each difference the way a reviewer would.
// A field is { type, required } (requests) or { type } (responses); enums list their values.
const v1 = {
  request: { sku: { type: "string", required: true }, qty: { type: "integer", required: true } },
  response: { id: { type: "string" }, status: { type: "enum", values: ["paid", "refunded"] }, total: { type: "integer" }, note: { type: "string" } },
};
const v2 = {
  request: { sku: { type: "string", required: true }, qty: { type: "integer", required: true }, giftWrap: { type: "boolean", required: false }, region: { type: "string", required: true } },
  response: { id: { type: "string" }, status: { type: "enum", values: ["paid", "refunded", "disputed"] }, total: { type: "string" }, eta: { type: "string" } },
};

function diff(oldC, newC) {
  const out = [];
  for (const [name, f] of Object.entries(newC.request)) {
    const was = oldC.request[name];
    if (!was) out.push([f.required ? "BREAKING" : "safe", `request field "${name}" added (${f.required ? "required: old clients don't send it" : "optional"})`]);
    else if (f.required && !was.required) out.push(["BREAKING", `request field "${name}" became required`]);
  }
  for (const [name, f] of Object.entries(oldC.response)) {
    const now = newC.response[name];
    if (!now) out.push(["BREAKING", `response field "${name}" removed (clients may read it)`]);
    else if (now.type !== f.type) out.push(["BREAKING", `response field "${name}" changed ${f.type} → ${now.type}`]);
    else if (f.type === "enum") {
      const added = now.values.filter((v) => !f.values.includes(v));
      if (added.length) out.push(["RISKY", `response enum "${name}" gained ${added.join(", ")} (strict switch statements break)`]);
    }
  }
  for (const name of Object.keys(newC.response)) {
    if (!oldC.response[name]) out.push(["safe", `response field "${name}" added (tolerant readers ignore it)`]);
  }
  return out;
}

for (const [kind, text] of diff(v1, v2)) console.log(kind.padEnd(9), text);
Result
safe      request field "giftWrap" added (optional)
BREAKING  request field "region" added (required: old clients don't send it)
RISKY     response enum "status" gained disputed (strict switch statements break)
BREAKING  response field "total" changed integer → string
BREAKING  response field "note" removed (clients may read it)
safe      response field "eta" added (tolerant readers ignore it)
What’s happening
  1. Requests are about what old clients send. giftWrap is new and optional: old clients don't send it, the server uses a default, nothing breaks. region is new and required: every old client's request now fails validation. The asymmetry is the rule: you may accept more, you may not demand more.
  2. Responses are about what old clients read. Removing note breaks any client that displays it, even if "hardly anyone uses it"; you only learn who did when they break. Changing total from integer to string breaks arithmetic in every typed client (and silently in JavaScript: "4200" + 100 is "4200100").
  3. New enum values are the grey area. Adding "disputed" to status is additive on paper, but a client with switch (status) { case "paid": … case "refunded": … default: throw } crashes on the first disputed order. Google's API design guide (AIP-180, checked October 2026) allows new enum values freely in request-only enums and says to use "appropriate caution" for enums in responses; the safest course is to document from day one that new values may appear, and to tell clients to handle unknown values.
  4. Adding eta is safe for tolerant readers, and that depends on clients: a strict schema validator that rejects unknown fields turns every additive change into a breaking one. Publish the rule "ignore unknown fields" in your API docs and SDKs.
  5. A diff like this, run in CI against the published OpenAPI or .proto contract, is how teams stop breaking changes from merging by accident. It can't see semantic changes, though: returning amounts in dollars instead of cents, or sorting a list differently, keeps the schema identical and still breaks clients. AIP-180 names three kinds of compatibility for this reason: source, wire and semantic.
AdvancedWhere the version goes
StyleExampleForAgainst
URL path/v1/orders, /v2/ordersobvious, cacheable, easy to route at the gatewaycoarse: one breaking change bumps everything; clients migrate all at once
HeaderX-GitHub-Api-Version: 2026-03-10, Accept: application/vnd.example.v2+jsonclean URLs, can version per requestinvisible in links and logs, easy to forget
Date-pinned per accountStripe: account pinned at first request, override with Stripe-Versionmany small breaking changes, each opt-in; new accounts get the latestthe server must keep transforms for every old version
No versions, evolve onlyGraphQL: add fields, mark old ones @deprecated; gRPC: new field numbersno migration events at allneeds discipline and usage tracking; removal is still a breaking change

Real examples, checked October 2026: GitHub's REST API uses the X-GitHub-Api-Version header; it supports 2026-03-10 and 2022-11-28, defaults to 2022-11-28 when the header is missing, promises to support the previous version for at least 24 months after a new one ships (2022-11-28 ends on March 10, 2028), and ships additive changes in all supported versions while breaking changes only go into a new version. Stripe describes its approach in "APIs as infrastructure: future-proofing Stripe with versioning" (August 2017): rolling versions named by release date, an account pinned to the latest version on its first request, almost a hundred backwards-incompatible upgrades in six years, and compatibility with every version since 2011.

AdvancedMany versions, one codebase

Stripe's trick for keeping dozens of versions alive is that the code only ever produces the latest response shape. Each breaking change ships with a small version change module that converts a response from that version back to the one before it, and the API applies them in reverse until it reaches the client's pinned version:

version-gates.mjs
// Date-based API versions, Stripe-style: the code only builds the LATEST shape of a response.
// Each version that made a breaking change also ships a "down" transform back to the previous shape.
const changes = [ // newest first
  {
    version: "2026-09-01",
    note: "status 'canceled' split into 'canceled' and 'expired'",
    down: (o) => ({ ...o, status: o.status === "expired" ? "canceled" : o.status }),
  },
  {
    version: "2026-03-01",
    note: "amount became { value, currency } instead of amount + currency",
    down: ({ amount, ...o }) => ({ ...o, amount: amount.value, currency: amount.currency }),
  },
  {
    version: "2025-06-01",
    note: "customer_name renamed to customer",
    down: ({ customer, ...o }) => ({ ...o, customer_name: customer }),
  },
];

function render(order, pinnedVersion) {
  let body = order;
  for (const change of changes) {
    if (change.version > pinnedVersion) body = change.down(body); // ISO dates compare correctly as strings
  }
  return body;
}

const latest = { id: "ord_1", customer: "Asha", amount: { value: 4200, currency: "cad" }, status: "expired" };

for (const pin of ["2026-10-01", "2026-03-01", "2025-12-15", "2025-01-01"]) {
  const applied = changes.filter((c) => c.version > pin).map((c) => c.version);
  console.log(`client pinned to ${pin} (transforms: ${applied.join(", ") || "none"})`);
  console.log("   ", JSON.stringify(render(latest, pin)));
}
Result
client pinned to 2026-10-01 (transforms: none)
    {"id":"ord_1","customer":"Asha","amount":{"value":4200,"currency":"cad"},"status":"expired"}
client pinned to 2026-03-01 (transforms: 2026-09-01)
    {"id":"ord_1","customer":"Asha","amount":{"value":4200,"currency":"cad"},"status":"canceled"}
client pinned to 2025-12-15 (transforms: 2026-09-01, 2026-03-01)
    {"id":"ord_1","customer":"Asha","status":"canceled","amount":4200,"currency":"cad"}
client pinned to 2025-01-01 (transforms: 2026-09-01, 2026-03-01, 2025-06-01)
    {"id":"ord_1","status":"canceled","amount":4200,"currency":"cad","customer_name":"Asha"}
What’s happening
  1. The handler builds one object in the latest shape: customer, a structured amount, and the new status value expired. No if (version < …) branches anywhere in business logic.
  2. A client pinned to 2026-10-01 is newer than every change, so no transform runs and it gets the object as built.
  3. A client pinned to 2026-03-01 predates the 2026-09-01 change, so that one transform runs and maps expired back to canceled, the only value its code knows. It still gets the structured amount, because it already opted into that.
  4. 2025-12-15 gets two transforms, newest first: status mapped back, then amount flattened into amount + currency. Order matters: each down function expects the shape produced by the version after it.
  5. 2025-01-01 gets all three, ending with the field renamed back to customer_name. Four clients, four shapes, one code path. The cost is that every transform lives forever (or until no account is pinned before it), and changes with side effects (not just shape) can't be expressed as a response transform; Stripe's post says those are checked explicitly in code instead.
AdvancedDeprecating and removing safely
  • Measure before you remove. Log the version (and ideally the fields used) per client, so "who still calls v1?" has an answer and a list of people to contact.
  • Announce in the response itself. The Deprecation header (RFC 9745, March 2025) says a resource is or will be deprecated, e.g. Deprecation: @1688169599, and the Sunset header (RFC 8594) gives the date it will stop working; RFC 9745 says the sunset date must not be earlier than the deprecation date. Add a Link to the migration guide.
  • Expand, then contract for renames and type changes: add the new field next to the old one, write both, migrate clients, watch usage of the old one fall to zero, then remove it. At no point is a client broken.
  • Brownouts before the final removal: turn the old version off for an hour, then a day, so forgotten integrations fail loudly while there's still time.
  • Mobile apps need a server-controlled minimum supported app version (a forced-upgrade screen), because you can't recall installed binaries.
  • Events and webhooks are an API too. Stripe formats each event according to the account's API version at the time the event was created, and stored events don't change when the account upgrades.
Advanced"It's only additive" can still break someone

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.