System Design NotesRohit’s interview study guide
Chapter 10

Case Studies: Core Services

Six classic backend prompts worked end to end, each in the same shape from requirements to trade-offs, with the numbers that drive every decision calculated and the core mechanism built as a model you can run.

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

#Design a URL shortener

Added

A URL shortener turns https://shop.example.com/campaigns/2026/spring?utm_source=newsletter&utm_medium=email into https://sho.rt/m0JvKvd, and when anyone opens the short link it answers with a redirect to the long one. It looks like a toy, which is exactly why interviewers like it: the functionality fits on a sticky note, so the whole 45 minutes goes on the parts that matter at scale. How do you hand out billions of short codes that never collide, without every server coordinating on every request? And how do you answer ten times as many redirects as you create links, in a few milliseconds, from anywhere?

A good mental model is a coat check. You hand over a coat (the long URL) and get a ticket (the code). Tickets must be unique, short enough to read out, and ideally not guessable, because whoever holds ticket 41 shouldn't be able to collect coat 42. Issuing tickets is the rare operation; looking a ticket up and fetching the coat is what happens all evening. So you make the lookup brutally fast (a cache in front of a key-value store), and you make ticket issuing cheap by giving each attendant a whole book of pre-numbered tickets instead of making them ask the manager for each one.

Everything else in the design follows from two numbers you will calculate below: about 1,200 new links a second against about 12,000 redirects a second, and 182 billion links after five years. The second number decides the code length (7 characters); the first pair decides that the read path gets a cache and the write path gets counter ranges.

The heart of the design is the code generator. Here are the two serious options side by side: a counter handed out in ranges and encoded in base62, and a hash of the URL with a collision check.

shortcodes.mjs
import { createHash } from "node:crypto";

const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const SPACE = 62n ** 7n; // every 7-character code: 3,521,614,606,208
const base62 = (n, width = 7) => {
  let s = "";
  for (; n > 0n; n /= 62n) s = ALPHABET[Number(n % 62n)] + s;
  return s.padStart(width, "0");
};

// Approach 1: a counter, handed out in ranges. One small table (or ZooKeeper node) owns "next free id";
// each app server grabs 1,000 ids at a time, so it talks to the allocator once per 1,000 links.
let nextFree = 1n;
const grabRange = (size = 1000n) => { const start = nextFree; nextFree += size; return { next: start, end: start + size }; };
const servers = ["app-1", "app-2", "app-3"].map((name) => ({ name, range: grabRange() }));
// Scramble the id so codes aren't guessable: three rounds of "multiply by a number coprime with 62^7,
// add a constant, reverse the 7 digits". Each step is a bijection, so two ids can never share a code.
const reverse = (x) => { let y = 0n; for (let i = 0; i < 7; i++, x /= 62n) y = y * 62n + (x % 62n); return y; };
const scramble = (x) => { for (const c of [11n, 23n, 37n]) x = reverse((x * 2_654_435_761n + c) % SPACE); return x; };
function counterCode(server) {
  if (server.range.next === server.range.end) server.range = grabRange();
  const id = server.range.next++;
  return { id, code: base62(scramble(id)) };
}
for (const s of servers) {
  const a = counterCode(s), b = counterCode(s);
  console.log(`${s.name}: id ${a.id} -> ${a.code}, id ${b.id} -> ${b.code}`);
}

// Approach 2: hash the long URL and keep the first few characters, checking the database for a clash.
// To make clashes visible, use 3-character codes (62^3 = 238,328 slots) and 20,000 URLs.
const hashCode = (url, salt, width) => {
  const n = BigInt("0x" + createHash("sha256").update(url + salt).digest("hex").slice(0, 16));
  return base62(n % 62n ** BigInt(width), width);
};
const taken = new Map(); // code -> url, standing in for the unique index on short_code
let clashes = 0, extraLookups = 0;
for (let i = 0; i < 20_000; i++) {
  const url = `https://example.com/article/${i}`;
  for (let salt = 0; ; salt++) {
    const code = hashCode(url, salt ? `#${salt}` : "", 3);
    extraLookups += salt > 0;
    if (!taken.has(code)) { taken.set(code, url); break; }
    clashes++;
  }
}
const m = 62 ** 3, k = 20_000;
console.log(`hash, 3 chars: ${clashes} clashes over ${k} inserts (${extraLookups} retries)`);
console.log(`birthday estimate k^2/2m = ${Math.round(k * k / (2 * m))}`);
// Same estimate at real scale: 7 characters, 182.5 billion links (100M a day for 5 years).
const K = 182.5e9, M = 62 ** 7;
console.log(`7 chars, ${K / 1e9}B links: ~${(K * K / (2 * M) / 1e9).toFixed(1)}B pairs would clash without a check`);
console.log(`chance the next insert clashes when full: ${(K / M * 100).toFixed(1)}%`);
Result
app-1: id 1 -> m0JvKvd, id 2 -> ax0adZ8
app-2: id 1001 -> jgd4x8T, id 1002 -> 6MMlVGs
app-3: id 2001 -> TRxm3YY, id 2002 -> HnQhBVE
hash, 3 chars: 942 clashes over 20000 inserts (942 retries)
birthday estimate k^2/2m = 839
7 chars, 182.5B links: ~4.7B pairs would clash without a check
chance the next insert clashes when full: 5.2%
What’s happening
  1. base62 writes a number in 62 symbols (0-9, a-z, A-Z), padded to 7 characters. Seven characters give 62^7 = 3,521,614,606,208 codes, which is the space everything else lives in (the encoder itself is explained in Base62 and short codes).
  2. grabRange is the only shared state: one row (or a ZooKeeper node) holding the next free id. Each app server takes 1,000 ids at a time, so app-1 owns 1–1,000, app-2 1,001–2,000 and app-3 2,001–3,000. After that, each server mints codes from memory with no network call at all, and two servers can never hand out the same id because their ranges don't overlap.
  3. A raw counter would give codes like 0000001, 0000002: anyone could walk through every link ever made. scramble fixes that with three rounds of "multiply by a number coprime with 62^7, add a constant, reverse the digits". Every step is a bijection on the 3.5 trillion codes (it can be undone), so distinct ids still give distinct codes, and neighbouring ids come out unrelated: id 1 is m0JvKvd, id 2 is ax0adZ8. It isn't encryption (a determined attacker with many samples could undo it); if links must be unguessable, use a keyed format-preserving cipher or random codes.
  4. The hash approach needs no allocator: SHA-256 the URL, keep a few base62 characters, and insert with the code as a unique key. To make clashes visible, the model uses only 3 characters (238,328 slots) for 20,000 URLs. It hit 942 clashes, each needing a salted retry and an extra lookup; the birthday estimate k²/2m predicts 839 first-try clashes, and retries that clash again plus randomness make up the rest.
  5. At real scale the same maths says 7-character hashes over 182.5 billion links would produce about 4.7 billion clashing pairs if you didn't check, and once the table holds 182.5 billion links, each new insert has a 5.2% chance of landing on a taken code. So "hash and truncate" is never safe on its own: it always needs the check-and-retry loop, and that loop is a read on every write that gets more expensive as the table fills.
  6. That's the trade in one run: counter ranges cost one tiny coordination call per 1,000 links and can never collide; hashing costs a uniqueness check on every insert but gives the same code for the same URL (free deduplication) with no allocator to run. The design below uses counter ranges.
AdvancedURL shortener requirements

Requirements

Ask before drawing. These are the answers this design assumes; say them out loud and write them in a corner of the board.

Functional:

  • Create a short link for a long URL; optionally with a custom alias (sho.rt/spring-sale) and an expiry date.
  • Redirect: opening the short link sends the browser to the long URL.
  • Click analytics for the link's owner: clicks per day, referrers, countries. Minutes of delay are fine.
  • Owners can disable or delete a link (abuse reports need this too).

Non-functional:

  • Redirects are the product. A broken short link is broken in every email, tweet and printed poster that contains it, so the redirect path targets 99.99% availability (about 52 minutes of downtime a year, see Availability, SLAs, SLOs and SLIs) and a p99 well under 50 ms at the server.
  • A short code must never point to the wrong URL: durability and uniqueness matter more than creation latency.
  • Codes should not be enumerable (people shorten private document links).
  • Scale: design for 100 million new links a day. Bitly, the best-known shortener, said in a February 2026 press release that it handles "hundreds of millions" of links and QR codes a month, so 100 million a day (3 billion a month) is roughly ten times that, a comfortable interview target.

Out of scope unless asked: user accounts and billing, QR codes, link-in-bio pages, A/B redirects.

AdvancedURL shortener estimates in code

Estimates

The assumptions: 100M new links a day, 10 redirects per link created, a peak of 3× the average, 500 bytes per stored link (code, long URL averaging a few hundred bytes, owner, timestamps, indexes), links kept 5 years, and a cache holding the hottest 20% of the last 30 days of links.

url-estimates.mjs
// Assumptions (stated in the text): 100M new links a day, 10 redirects per link created,
// peak = 3x average, 500 bytes per stored link, keep links 5 years, the cache holds 20% of the last 30 days of links.
const DAY = 86_400;
const writesPerDay = 100e6;
const readsPerDay = writesPerDay * 10;
const peak = 3;
const bytesPerLink = 500;
const years = 5;

const writeQps = writesPerDay / DAY;
const readQps = readsPerDay / DAY;
const links = writesPerDay * 365 * years;
const storageTB = (links * bytesPerLink) / 1e12;
const fmt = (n) => Math.round(n).toLocaleString("en-US");

console.log(`writes: ${fmt(writeQps)}/s average, ${fmt(writeQps * peak)}/s peak`);
console.log(`redirects: ${fmt(readQps)}/s average, ${fmt(readQps * peak)}/s peak`);
console.log(`links after ${years} years: ${(links / 1e9).toFixed(1)} billion`);
console.log(`storage: ${storageTB.toFixed(1)} TB (x3 replicas = ${(storageTB * 3).toFixed(0)} TB)`);
for (const len of [6, 7, 8]) {
  const space = 62 ** len;
  const used = links / space;
  console.log(`${len} chars: ${(space / 1e9).toFixed(1)}B codes, ${used > 1 ? "too few" : `${(used * 100).toFixed(1)}% used after ${years} years`}`);
}
// Cache: the hottest 20% of the last 30 days of links (older links are rarely clicked).
const hotLinks = writesPerDay * 30 * 0.2;
console.log(`cache for ~${(hotLinks / 1e6).toFixed(0)}M hot links: ${((hotLinks * bytesPerLink) / 1e9).toFixed(0)} GB`);
// Egress: a redirect response is ~500 bytes of headers.
console.log(`redirect egress at peak: ${((readQps * peak * 500 * 8) / 1e6).toFixed(0)} Mbit/s`);
Result
writes: 1,157/s average, 3,472/s peak
redirects: 11,574/s average, 34,722/s peak
links after 5 years: 182.5 billion
storage: 91.3 TB (x3 replicas = 274 TB)
6 chars: 56.8B codes, too few
7 chars: 3521.6B codes, 5.2% used after 5 years
8 chars: 218340.1B codes, 0.1% used after 5 years
cache for ~600M hot links: 300 GB
redirect egress at peak: 139 Mbit/s
What’s happening
  1. 100M writes over 86,400 seconds is 1,157 writes/s, 3,472/s at peak. One well-sized database primary handles that comfortably: the write rate is not what forces sharding. Size is: step 4 below comes to about 91 TB, which no single primary holds, so the store is sharded by code from the start.
  2. Redirects are ten times that: 11,574/s average, 34,722/s at peak. This is the number the cache exists for: 35,000 reads a second against a disk-backed store is possible but expensive, against Redis it is a handful of nodes.
  3. Five years is 182.5 billion links. Six characters give only 56.8 billion codes ("too few"); seven characters give 3.5 trillion, of which you would use 5.2%. Eight is wasteful (0.1% used) and makes every link longer forever. That single line is how you justify "7 characters" in the interview.
  4. Storage is 91 TB, 274 TB with three replicas: far beyond one machine, and this is the number that decides sharding. The links store is a sharded key-value or wide-column store, or a sharded relational database.
  5. The cache for 600 million hot links is about 300 GB: a small Redis cluster. The deep dive below checks how much of the traffic a cache of a given size actually absorbs.
  6. Redirect responses are tiny, so egress is only about 139 Mbit/s at peak. Bandwidth is not the constraint here; request rate and latency are.
AdvancedURL shortener API sketch

API

Three public endpoints and one redirect. Creation is a normal JSON API; the redirect is a bare GET on the short domain so it works from any browser.

API sketch
POST /api/v1/links
  Idempotency-Key: 6f1c…            (a retried create must not mint a second code)
  { "url": "https://shop.example.com/…", "alias": "spring-sale", "expiresAt": "2026-12-31T00:00:00Z" }
  -> 201 Created  { "code": "m0JvKvd", "shortUrl": "https://sho.rt/m0JvKvd", "expiresAt": "…" }
  -> 409 Conflict if the alias is taken; 400 if the URL is malformed or on a blocklist

GET /{code}
  -> 302 Found   Location: https://shop.example.com/…   Cache-Control: private, max-age=90
  -> 404 Not Found if unknown; 410 Gone if disabled or expired

GET    /api/v1/links/{code}/stats?from=2026-10-01&to=2026-10-24   -> clicks per day, top referrers, countries
DELETE /api/v1/links/{code}                                        -> 204 (soft delete: the code is never reused)

The Idempotency-Key header makes a timed-out create safe to retry, as explained in Idempotency keys for APIs. The choice between 301 and 302 on the redirect is one of the deep dives below, because it decides whether you can count clicks at all.

AdvancedURL shortener data model

Data model

The access pattern is almost entirely point lookups by code: "give me the URL for m0JvKvd". There are no joins on the hot path and no range scans. That favours a key-value or wide-column store partitioned by code (DynamoDB, Cassandra) or a relational database sharded by a hash of the code. Here is the logical schema in SQL:

SQL
CREATE TABLE links (
  code        varchar(16) PRIMARY KEY,      -- 7 chars generated, longer for custom aliases
  long_url    text        NOT NULL,
  owner_id    bigint,                        -- null for anonymous links
  created_at  timestamptz NOT NULL DEFAULT now(),
  expires_at  timestamptz,
  status      smallint    NOT NULL DEFAULT 0 -- 0 active, 1 disabled, 2 deleted
);
CREATE INDEX links_by_owner ON links (owner_id, created_at DESC);  -- "my links" page

CREATE TABLE id_ranges (                     -- the range allocator: one row
  name     text PRIMARY KEY,
  next_id  bigint NOT NULL
);
-- grab 1,000 ids atomically:
-- UPDATE id_ranges SET next_id = next_id + 1000 WHERE name = 'links' RETURNING next_id - 1000;

CREATE TABLE clicks_daily (                  -- written by the analytics pipeline, not the redirect path
  code    varchar(16),
  day     date,
  clicks  bigint NOT NULL,
  PRIMARY KEY (code, day)
);

Two details are worth saying. The links_by_owner index is a secondary index in a sharded system: if links is sharded by code, the owner's links are spread across every shard, so either keep a separate owner-keyed table or accept scatter-gather for that rare page (see Secondary indexes in a sharded system). And deleted codes are never reused: a reused code would silently send old links in old emails to someone else's page.

AdvancedURL shortener architecture

High-level design

Clients browsers, apps Edge + LB TLS, routing DDoS shield bot filtering Create service POST /links Redirect service GET /{code} Range allocator 1,000 ids a grab Cache (Redis) code → URL Links store sharded by code 3 replicas Click events Kafka topic Analytics clicks per day on a miss: read store dashed = async
Two paths with very different budgets: the rare create path coordinates once per 1,000 links; the hot redirect path is a cache lookup plus a fire-and-forget event.

Walk the board in this order:

  1. Create: the client posts the long URL. The create service validates it (scheme, length, blocklist), takes the next id from its in-memory range (grabbing a new range from the allocator every 1,000 links), scrambles and encodes it, and writes code → long_url to the links store with a conditional "insert if absent". It returns the short URL. With a custom alias, the code is the alias and the conditional insert is what detects "taken".
  2. Redirect: the browser requests GET /m0JvKvd. The redirect service looks the code up in Redis; on a hit (most requests, see below) it answers 302 immediately. On a miss it reads the links store, fills the cache, and answers.
  3. Analytics: after answering, the redirect service publishes a click event (code, time, referrer, country from the IP, user agent) to Kafka without waiting for it. A stream job aggregates clicks per code per day into clicks_daily; raw events go to object storage for anything else.
  4. Both services are stateless, so they scale horizontally behind the load balancer; all state lives in the allocator, the cache, the store and Kafka.
AdvancedDeep dive: generating short codes

There are three families of approach. The model above ran two of them; this table puts all three next to each other.

ApproachHow it worksCollisionsCoordinationWeakness
Counter + base62Global counter, encode the numberImpossibleOne call per range (1,000 ids)Sequential unless scrambled; allocator must be highly available
Hash + truncatebase62(sha256(url))[0..7]Grow with table size; 5.2% per insert when fullNone, but a uniqueness check per insertRead before every write; same URL from two users shares a code
Random7 random base62 charactersSame maths as hashingNone, check per insertSame check-and-retry cost; no dedup

The counter wins for this design because the numbers say so: at 1,157 writes a second, a check-and-retry read on every write is real load on the store, and it gets worse as the store fills; the counter's coordination cost is one allocator call per 1,000 links, about 1.2 allocator calls a second at average load. Ranges also survive server crashes cheaply: if app-2 dies holding ids 1,001–2,000, those unused ids are simply skipped. Out of 3.5 trillion codes, losing a few thousand per crash doesn't matter.

The allocator is now the thing to make reliable. It's one row, so a single strongly consistent database (or a ZooKeeper/etcd counter) is enough; give it a replica with automatic failover and make app servers grab their next range when they are 80% through the current one, so a short allocator outage never blocks creation. Unique ID generation compares this "ticket server" idea with Snowflake-style IDs, which avoid even the allocator by embedding the machine id in the number. Snowflake IDs are 64-bit and time-ordered, which would make codes 11 base62 characters long, so ranges suit a shortener better.

AdvancedDeep dive: the redirect path and its cache

A redirect is a key lookup, so a cache in front of the store is obvious; the real question is how much traffic a cache of a given size absorbs. Click traffic is heavily skewed: a link in a viral post gets millions of clicks, most links get a handful. The usual model for that is a Zipf distribution, where the k-th most popular item gets traffic proportional to 1/k^s. The exponent for your traffic is something you'd measure; here are two plausible values (an assumption, not a measured property of any real shortener):

redirect-cache.mjs
// How big must the redirect cache be? Clicks follow a power law: a few links get most of the traffic.
// Model: 1,000,000 links, clicks drawn from a Zipf distribution (exponent s), LRU caches of several sizes.
// Seeded PRNG (mulberry32), so every run prints the same numbers.
function mulberry32(a) {
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const LINKS = 1_000_000, CLICKS = 3_000_000;

function zipfSampler(s, rand) {
  const cdf = new Float64Array(LINKS);
  let sum = 0;
  for (let i = 0; i < LINKS; i++) cdf[i] = sum += 1 / (i + 1) ** s;
  return () => { // binary search for the first rank whose cumulative weight passes a uniform draw
    const u = rand() * sum;
    let lo = 0, hi = LINKS - 1;
    while (lo < hi) { const mid = (lo + hi) >> 1; if (cdf[mid] < u) lo = mid + 1; else hi = mid; }
    return lo;
  };
}

function hitRate(capacity, s) {
  const next = zipfSampler(s, mulberry32(42));
  // LRU as a doubly linked list over typed arrays (link ids are 0..LINKS-1; LINKS is the list's head sentinel).
  const prev = new Int32Array(LINKS + 1).fill(-1), nxt = new Int32Array(LINKS + 1).fill(-1);
  const H = LINKS; prev[H] = nxt[H] = H;
  const unlink = (x) => { nxt[prev[x]] = nxt[x]; prev[nxt[x]] = prev[x]; };
  const toFront = (x) => { prev[x] = H; nxt[x] = nxt[H]; prev[nxt[H]] = x; nxt[H] = x; };
  let size = 0, hits = 0;
  for (let i = 0; i < CLICKS; i++) {
    const link = next();
    if (prev[link] !== -1) { hits++; unlink(link); }
    else if (size >= capacity) { const old = prev[H]; unlink(old); prev[old] = -1; } // evict the tail
    else size++;
    toFront(link);
  }
  return hits / CLICKS;
}

for (const s of [0.8, 1.0]) {
  const row = [0.001, 0.01, 0.05, 0.2].map((f) => `${(f * 100).toString().padStart(4)}% -> ${(hitRate(LINKS * f, s) * 100).toFixed(1)}%`);
  console.log(`s=${s.toFixed(1)}  cache size -> hit rate:  ${row.join("   ")}`);
}
Result
s=0.8  cache size -> hit rate:   0.1% -> 10.0%      1% -> 23.2%      5% -> 38.9%     20% -> 59.0%
s=1.0  cache size -> hit rate:   0.1% -> 40.3%      1% -> 58.5%      5% -> 71.6%     20% -> 82.2%
What’s happening
  1. zipfSampler builds a cumulative weight table over a million links (link k has weight 1/(k+1)^s) and samples by binary search, so popular links come up far more often. The PRNG is seeded, so the hit rates are identical on every run.
  2. The LRU is a doubly linked list in two Int32Arrays: a hit unlinks the link and puts it at the front; a miss on a full cache evicts the tail (the least recently used link). It's the same policy as the Map-based LRU in Eviction policies: LRU, LFU and TTL, written with arrays because a Map with millions of deletions got slow enough to break the 30-second budget.
  3. With s=1.0, caching just 0.1% of links serves 40% of redirects, and 20% of links serves 82%. With the flatter s=0.8, the same 20% serves only 59%. The skew of your traffic decides the cache size, which is why "cache 20% and get 80%" is a rule of thumb to check, not a law.
  4. The run also counts cold misses: 3 million clicks over a million links means many links are seen for the first time. A long-running cache that only sees its working set does better than these numbers, so treat them as a floor.
  5. For the design: at 34,722 redirects a second and an 80% hit rate, the links store sees about 7,000 reads a second at peak instead of 35,000, and the 300 GB cache from the estimates is the 20%-of-recent-links column.

Three more things belong on the redirect path:

  • Negative caching. Bots probe random codes. Cache "not found" for a short time (say 60 s) too, or every probe goes to the store. A Bloom filter of existing codes in front of the store is the other way to stop them (see Bloom filters).
  • Hot links. A code going viral is a hot key: tens of thousands of hits a second on one Redis shard. A small in-process cache in each redirect server (a few thousand entries, a few seconds of TTL) absorbs it; Hot keys and cache stampedes covers the options.
  • Edge caching. A CDN could answer redirects itself if the response were cacheable by shared caches. That is the strongest latency win and the biggest analytics loss (the clicks it answers never reach you, unless you process the CDN's logs), so this design sends Cache-Control: private: the browser may reuse the redirect for 90 seconds, shared caches may not. The same trade-off decides the status code.

301 or 302? RFC 9110 (the HTTP semantics standard, 2022) defines 301 Moved Permanently as heuristically cacheable: without explicit cache headers, browsers and proxies may store it and send later clicks straight to the long URL without asking you. 302 Found means "temporarily elsewhere" and isn't cached unless you say so. So:

Without explicit cache headers, the two behave like this:

301 (or 308)302 (or 307)
Later clicks from the same browserOften never reach youReach you every time (or after max-age)
Click analyticsUndercountedAccurate
Load on your serversLowerHigher
Changing or disabling a linkCached copies keep redirectingTakes effect at once (or after max-age)

In practice the explicit Cache-Control header matters more than the status code. Bitly, which sells click analytics, answers 301 with Cache-Control: private, max-age=90, and TinyURL answers 301 with Cache-Control: no-store (both checked with curl -I, October 2026): the header caps how long a browser may reuse the redirect, so the "permanent" status costs them little. This design answers 302 with the same short private max-age: the header does the real work, and 302 means a cache that ignores or loses the header still won't treat the link as permanent. The ability to disable a malicious link quickly matters more than the saved requests. (307/308 are the versions that forbid changing POST to GET; for plain link clicks, which are GETs, they behave the same as 302/301.)

AdvancedDeep dive: analytics without slowing the redirect

Counting clicks synchronously (UPDATE … SET clicks = clicks + 1 on every redirect) would turn a 35,000-reads-a-second path into 35,000 writes a second on a handful of hot rows. Instead the redirect service publishes an event and moves on, which is the Pub/sub and fan-out pattern:

  • Events go to a Kafka topic partitioned by code, so one consumer sees all clicks for a code in order. The catch is the viral link again: every click on it lands on one partition, so one broker and one consumer take tens of thousands of events a second while the rest idle. Click counts don't need per-code order, so the fixes are to partition by a hash of the event id (or randomly) and aggregate per code downstream (each consumer keeps partial counts, and the clicks_daily upsert adds them), or to pre-aggregate at the producer: each redirect server counts clicks per code in memory and publishes one "code, +n" event every few seconds.
  • A stream job (Flink, Kafka Streams, or a plain consumer) keeps per-code counters in memory and flushes clicks_daily every minute: 35,000 events a second (2.1 million a minute) become one upsert per code clicked in that minute, so a viral link's thousands of clicks cost one write, not thousands. (The long tail of links clicked once or twice still costs about one write per click; batch those upserts.)
  • "Unique visitors" per link is a count-distinct problem; a HyperLogLog per code per day estimates it with a standard error of 0.81% in at most 12 KB (Redis's implementation) (see HyperLogLog and count-min sketch).
  • If Kafka is down, the redirect still works; the service buffers events in memory, can spool them to local disk to ride out a longer outage and replay them afterwards, and drops them only when that fills up. Losing some clicks is acceptable by the requirements; failing redirects is not. Say that priority out loud.
AdvancedDeep dive: aliases, expiry and abuse
  • Custom aliases share the code key space. The create service does a conditional insert of the alias; a clash returns 409. Reserve aliases that look like generated codes (exactly 7 base62 characters) for the generator, or a user could squat a future code.
  • Expiry is checked on read (expires_at < now() → 410 Gone), and a background job deletes expired rows lazily. The cache entry's TTL should never outlive the link's expiry.
  • Abuse. Shorteners hide phishing links. Check new URLs against a blocklist or a reputation service at creation time and re-scan popular links in the background; a disabled link answers 410 within the cache's TTL, which is another reason to keep TTLs short.
  • Same URL twice. With counter ranges, two users shortening the same URL get two codes. That's usually what you want (separate analytics per owner). If you want dedup, keep a hash(long_url, owner) → code table and look it up first.
AdvancedURL shortener trade-offs

Trade-offs and what to say

Trade-offs to name before the interviewer does:

  • Counter vs hash: the counter needs a small, highly available allocator; the hash needs a check on every write and grows more collisions over time. Ranges make the allocator cheap.
  • 302 vs 301: you pay for every repeat click in exchange for accurate analytics and instant link changes.
  • Consistency: a just-created link must redirect at once. Write to the store first, then return; the cache is filled on the first read, so for a generated code there's no stale entry to invalidate. A custom alias is the exception: if someone (or a bot) opened sho.rt/spring-sale before it existed, the cache holds a 60-second "not found" entry for it, so creating the alias must also delete that negative entry, or the new link answers 404 for up to a minute. Cross-region, either route creates to one home region and replicate (a link created in Europe may take a second to work in Asia), or allocate disjoint id ranges per region so each region creates locally.
  • What breaks first at 10× scale: the hot-key path (viral links) and the cache size, not the database writes.
Why not make the code a 6-character hash and handle collisions?

Your interviewer suggests: "Six characters is shorter. Hash the URL, take 6 base62 characters, and retry on collision. Why not?"

Show answer

Six characters can't hold five years of links at all, and the retry loop gets steadily worse long before you run out.

What’s happening
  1. Six base62 characters give 62^6 = 56.8 billion codes; the design needs 182.5 billion. The estimates block prints "6 chars: 56.8B codes, too few": the table would be full in about 1.6 years.
  2. Collision probability per insert equals the fraction of codes already taken. After one year (36.5 billion links) that's 36.5 / 56.8 = 64%: most inserts would need at least one retry, and many several.
  3. Every retry is another read against the store on the write path, so write latency and store load climb month by month.
  4. With 7 characters the same year-one table is 1% full (36.5 billion of 3.5 trillion), and with counter ranges there are no collisions at all, so the "shorter" code costs far more than the one character it saves.

#Design a rate limiter

Added

A rate limiter decides, for every incoming request, "is this caller still within their allowance?", and rejects the ones that aren't with 429 Too Many Requests. It protects your services from abusive or buggy clients, keeps one tenant from starving the others, enforces paid tiers, and caps your bill for expensive downstream calls. The algorithms (token bucket, fixed and sliding windows) are covered in Rate limiting algorithms; this case study is about the part that makes it a system design question: you have 200 gateway nodes and one limit per caller, so where does the count live, and how stale can it be?

The mental model is a nightclub with many doors and one guest list. Each door has a bouncer with a clicker. If every bouncer phones the manager before letting anyone in, the count is exact but every guest waits for the phone call, and when the phone line is down nobody gets in. If each bouncer only uses their own clicker, nobody waits, but a group that spreads across four doors gets in four times. The practical answer is in between: bouncers click locally and phone in their totals every second, accepting that for a second a determined group can slip a few extra people past.

That "every second" is the number this design is about. The model below measures exactly how many extra requests each sync interval lets through and how many store round trips it costs.

synced-limiter.mjs
// A sliding-window counter shared by 4 gateway nodes. Limit: 100 requests per 60 s per user.
// Each node counts locally and syncs with the shared store (Redis, here a Map) every `syncMs`.
// syncMs = 0 means "no local state": every request reads and increments the store (the exact baseline).
const LIMIT = 100, WINDOW = 60_000, NODES = 4;

function run(syncMs) {
  const store = new Map(); // window start -> global count: the Redis key user:42:<window>
  const nodes = Array.from({ length: NODES }, () => ({ known: new Map(), pending: new Map(), lastSync: -Infinity }));
  let storeTrips = 0;
  const allowed = [];
  const sync = (node) => { // one pipelined round trip: INCRBY my pending counts, read back the totals
    storeTrips++;
    for (const [w, n] of node.pending) store.set(w, (store.get(w) ?? 0) + n);
    node.pending.clear();
    node.known = new Map(store);
  };
  // One abusive client: 50 requests a second for 3 minutes, spread round-robin over the nodes.
  for (let t = 0, i = 0; t < 180_000; t += 20, i++) {
    const node = nodes[i % NODES];
    if (t - node.lastSync >= syncMs) { sync(node); node.lastSync = t; }
    const cur = Math.floor(t / WINDOW) * WINDOW;
    const count = (w) => (node.known.get(w) ?? 0) + (node.pending.get(w) ?? 0);
    // Sliding-window counter: weight the previous window by how much of it still overlaps the last 60 s.
    const estimate = count(cur - WINDOW) * (1 - (t - cur) / WINDOW) + count(cur);
    if (estimate < LIMIT) {
      allowed.push(t);
      if (syncMs === 0) store.set(cur, (store.get(cur) ?? 0) + 1); // same atomic Lua call as the read
      else node.pending.set(cur, (node.pending.get(cur) ?? 0) + 1);
    }
  }
  // Worst case: the most requests let through in any 60-second span.
  let worst = 0;
  for (let a = 0, b = 0; b < allowed.length; b++) {
    while (allowed[b] - allowed[a] >= WINDOW) a++;
    worst = Math.max(worst, b - a + 1);
  }
  const label = syncMs === 0 ? "every request" : `every ${syncMs / 1000} s`;
  console.log(`sync ${label.padEnd(13)} allowed ${String(allowed.length).padStart(3)} in 180 s, worst 60 s span ${String(worst).padStart(3)}, store trips ${storeTrips}`);
}

for (const syncMs of [0, 100, 1000, 5000]) run(syncMs);
Result
sync every request allowed 300 in 180 s, worst 60 s span 100, store trips 9000
sync every 0.1 s   allowed 306 in 180 s, worst 60 s span 108, store trips 4500
sync every 1 s     allowed 348 in 180 s, worst 60 s span 139, store trips 696
sync every 5 s     allowed 563 in 180 s, worst 60 s span 289, store trips 144
What’s happening
  1. The rule is 100 requests per 60-second sliding window for one user, enforced by 4 gateway nodes. The client is abusive: 50 requests a second for 3 minutes (9,000 requests), spread round-robin, so each node sees every fourth one. An exact limiter should let through exactly 100 per minute: 300 in 180 s.
  2. Each node keeps known (the global counts as of its last sync) and pending (what it admitted since). The estimate is the sliding-window counter: the previous fixed window's count weighted by how much of it still overlaps the last 60 s, plus the current window's count. At t = 90 s, a previous window with 100 requests counts as 50.
  3. syncMs = 0 is the exact baseline: every request is one atomic call to the store (in Redis, one Lua script that reads and increments). It admits 300, never more than 100 in any 60 s span, and costs 9,000 store trips, one per request, including the 8,700 rejected ones.
  4. Syncing every 0.1 s halves the trips (4,500: a node syncs at most every other request it sees) and the worst minute admits 108 instead of 100. Every 1 s: 696 trips (13× fewer than exact) and a worst minute of 139. Every 5 s: 144 trips but 289 in the worst minute, nearly 3× the limit.
  5. The overshoot grows with the sync interval because, between syncs, each node only knows its own admissions. Roughly, a determined client gets an extra (its request rate × the sync interval) through: 50/s × 1 s is about 50, and the model shows 39 extra. That's the number to quote: the error is bounded by the attacker's rate times the staleness, and you choose the staleness.
  6. The conclusion for the design: a 1-second sync costs about a thirteenth of the store traffic of the exact approach, takes the store off the request path entirely, and lets an abusive client exceed its limit by tens of percent for a short time. For API quotas that is fine; for "at most 3 password attempts" it isn't, and those rules use the exact path.
AdvancedRate limiter requirements

Requirements

Functional:

  • Limit requests per key: API key, user id, IP address, or a combination (apiKey + endpoint).
  • Several rules can apply to one request: a burst rule (20 per second) and a quota rule (10,000 per day). The request passes only if every rule passes.
  • Rules are configured without a deploy and take effect within seconds.
  • Rejected requests get 429 with Retry-After (429 comes from RFC 6585, which says the response may include Retry-After, a header defined in RFC 9110 as seconds or a date), and every response tells the client where it stands.

Non-functional:

  • Low added latency: the limiter is on every request, so its p99 budget is a millisecond or less.
  • Highly available: if the limiter's store is down, the API must not go down with it. Most rules fail open (allow) with a local fallback limit; security rules such as login attempts may fail closed.
  • Approximate is acceptable for quota rules (a few percent over for a few seconds); exact for security rules.
  • Scale: 1 million requests a second at peak across 200 gateway nodes, 5 million distinct callers active in any minute.

Real products show what "limits" look like: GitHub's REST API allows 5,000 requests an hour for an authenticated user and 60 an hour per IP unauthenticated, and Stripe's API allows 100 requests a second in live mode by default (both from their API docs, checked October 2026).

AdvancedRate limiter estimates in code

Estimates

Assumptions: 1M requests/s at peak through 200 gateways; 5M callers active in a minute; one sliding-window rule per caller, which is two counters (this window and the previous one); about 100 bytes per Redis counter including the key and Redis's own overhead; about 100,000 simple operations a second per Redis shard (a rule of thumb: the Redis docs' own redis-benchmark example shows about 180,000 SETs a second on their test machine, and real traffic with Lua scripts and network round trips gets less); a 1-second sync; and one attacker sending 20,000 requests a second.

limiter-estimates.mjs
// Assumptions (stated in the text): 1M requests/s at peak through 200 gateway nodes; 5M distinct callers
// active in any minute; one sliding-window rule per caller (2 counters: this window and the last);
// ~100 bytes per Redis counter including key and overhead; ~100k simple ops/s per Redis shard (rule of thumb);
// sync interval 1 s; an attacker sending 20,000 requests/s.
const peakRps = 1_000_000, gateways = 200, activeCallers = 5_000_000;
const bytesPerCounter = 100, opsPerShard = 100_000, syncSeconds = 1, attackerRps = 20_000;
const fmt = (n) => Math.round(n).toLocaleString("en-US");

const memoryGB = (activeCallers * 2 * bytesPerCounter) / 1e9;
console.log(`counter memory: ${memoryGB.toFixed(1)} GB (fits one Redis node; shard for throughput, not size)`);

// Exact: one atomic Lua call per request.
const exactOps = peakRps;
console.log(`exact: ${fmt(exactOps)} Redis calls/s -> ${Math.ceil(exactOps / opsPerShard)} shards, +1 network hop on every request`);

// Synced: each gateway sends one pipelined batch per second holding an INCRBY per caller it saw.
const trips = gateways / syncSeconds;
console.log(`synced: ${fmt(trips)} pipelined trips/s, 0 hops on the request path`);
// Worst case: every request a gateway saw in the interval came from a different caller.
const perGateway = (peakRps / gateways) * syncSeconds;
const worstCommands = (gateways * perGateway) / syncSeconds;
console.log(`synced worst case: ${fmt(perGateway)} distinct callers per gateway -> ${fmt(worstCommands)} INCRBY/s + reads of totals, pipelined`);

// The attacker is where the two differ most.
const attackerExact = attackerRps;
const attackerSynced = Math.min(gateways, attackerRps) / syncSeconds;
console.log(`attacker at ${fmt(attackerRps)} req/s costs ${fmt(attackerExact)} ops/s exact vs ${fmt(attackerSynced)} ops/s synced`);
Result
counter memory: 1.0 GB (fits one Redis node; shard for throughput, not size)
exact: 1,000,000 Redis calls/s -> 10 shards, +1 network hop on every request
synced: 200 pipelined trips/s, 0 hops on the request path
synced worst case: 5,000 distinct callers per gateway -> 1,000,000 INCRBY/s + reads of totals, pipelined
attacker at 20,000 req/s costs 20,000 ops/s exact vs 200 ops/s synced
What’s happening
  1. Memory is small: 5M callers × 2 counters × 100 bytes is 1 GB. The counter store is sized for throughput, not capacity, which is the opposite of most storage problems.
  2. The exact approach makes 1,000,000 Redis calls a second (rejected requests are checked too), which is about 10 shards at 100k ops/s each, and adds a network round trip (typically a few hundred microseconds inside a data centre) to every single request.
  3. The synced approach sends one pipelined batch per gateway per second: 200 round trips a second for the whole fleet. Each batch still carries one INCRBY per caller the gateway saw, but they travel together, off the request path.
  4. Trips are not commands, though. In the worst case every request a gateway sees in a second comes from a different caller (5,000 each), so the batches still carry about 1,000,000 INCRBYs a second, plus the reads of the totals: no fewer commands than the exact path. The savings are elsewhere: 200 round trips instead of a million, nothing on the request path, and repeat callers collapse into one command per sync, so real traffic, where active callers send many requests a second, costs far less than the worst case. Pipelined commands are also cheaper for Redis to serve: its pipelining docs explain that one read() and one write() system call carry many commands, and show throughput rising to about 10× the unpipelined rate. Size the counter store for the worst-case command rate, not for the 200 trips.
  5. The attacker line is the one that decides it. Exact limiting costs 20,000 store ops a second for one abusive client, so the traffic you most want to drop is the traffic you pay most to examine. Synced, the same attacker costs at most one update per gateway per second: 200 ops/s.
  6. So: exact for a few small, sensitive rules; local counting with a 1-second sync for the bulk of API traffic.
AdvancedRate limiter API sketch

API

The limiter has two interfaces: the internal call the gateway makes per request, and what the client sees.

Limiter interface and HTTP responses
check(key = "apikey:9f2c", rules = ["burst", "daily"], cost = 1)
  -> { allowed: false, rule: "burst", limit: 20, remaining: 0, resetMs: 640 }

HTTP/1.1 200 OK
RateLimit-Policy: "burst";q=20;w=1, "daily";q=10000;w=86400
RateLimit: "burst";r=13;t=1

HTTP/1.1 429 Too Many Requests
Retry-After: 1
RateLimit: "burst";r=0;t=1
Content-Type: application/problem+json
{ "type": "https://api.example.com/problems/rate-limited", "title": "Too many requests", "rule": "burst" }

RateLimit-Policy and RateLimit come from the IETF draft "RateLimit header fields for HTTP" (draft-ietf-httpapi-ratelimit-headers-11, May 2026). As of October 2026 it is still an Internet-Draft, not an RFC, so many APIs send their own X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset instead (GitHub does). Retry-After and 429 are standard. The cost parameter lets an expensive endpoint (a search, an export) spend more than one unit.

AdvancedRate limiter data model

Data model

  • Counters (Redis): rl:{apikey:9f2c}:burst:<window number> → integer, with a TTL of two windows so old windows clean themselves up. The braces are a Redis Cluster hash tag: only the part inside {} is hashed, so all of a caller's counters land on the same shard and one Lua script can touch them atomically.
  • Rules (a config store or a small database, cached in every gateway): { id: "burst", match: { tier: "free" }, limit: 20, windowSec: 1, algorithm: "sliding-window", mode: "approximate", failMode: "open" }.
  • Overrides: per-key exceptions (apikey:9f2c gets 10× for a migration weekend), in the same rules store.
  • Nothing is durable in the counter store: losing it resets everyone's counts, which for rate limiting is harmless. So no replication-for-durability, just enough replicas for availability.
AdvancedRate limiter architecture

High-level design

Clients API callers 429 + Retry-After API gateway nodes node 1 local counts, rules node 2 local counts, rules node 3 local counts, rules … 200 nodes Rules config store Counter store Redis cluster exact rules: Lua Services allowed calls only allowed push 1 s dashed = off the request path
The decision is made inside the gateway from local state; the shared counter store is consulted in the background (or synchronously, only for the few rules that must be exact).
  1. Where it runs. In the API gateway (or a sidecar next to each service), so rejected requests never reach the services and every service gets limiting without writing code. A limiter inside each service works too, but every team has to adopt it, and abusive traffic has already reached them.
  2. Per request: the gateway identifies the caller (API key from the header, user from the token, else the IP), finds the matching rules in its local rules cache, and checks each rule against its local view of the counts. Pass: forward and add the RateLimit headers. Fail: answer 429 itself.
  3. Every second: each gateway sends one pipelined batch to the counter store: INCRBY its pending counts per key, read back the global totals for the keys it has seen recently. Its local view is now at most one second stale.
  4. Exact rules (login attempts, OTP sends, money-moving endpoints) skip the local view and run the Lua script below against the store, synchronously. They are a small share of traffic, so their cost is small.
  5. Rules change in the config store and are pushed to gateways (or polled every few seconds); a gateway that can't reach the config store keeps the last rules it had.
AdvancedDeep dive: the exact path, one atomic script

When the count must be exact, the check and the increment must happen atomically, or two gateways can both read "99" and both admit request 100. In Redis the standard way is a Lua script, which Redis runs without interleaving other commands. This is the sliding-window counter from the model, written for Redis:

sliding-window.lua
-- KEYS[1] = this window's counter, KEYS[2] = the previous window's (same {hash tag}, so same cluster slot)
-- ARGV[1] = now in ms, ARGV[2] = window in ms, ARGV[3] = limit
local now, window, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local previous = tonumber(redis.call('GET', KEYS[2]) or '0')
local overlap = 1 - (now % window) / window
local estimate = previous * overlap + current
if estimate >= limit then
  return {0, limit - math.ceil(estimate)}
end
redis.call('INCR', KEYS[1])
redis.call('PEXPIRE', KEYS[1], window * 2)
return {1, limit - math.ceil(estimate) - 1}

It was run against Redis 8.10.2 (in Docker) with this script: 100 requests at t = 1 s, a 101st, then 61 more at t = 90 s. Timestamps are passed in, not read from a clock, so the run is reproducible:

lua-run.sh
SCRIPT=$(cat sliding-window.lua)
check() { # check <now ms>: this window's key, the previous window's key, now, window, limit
  local w=$(( $1 / 60000 ))
  redis-cli EVAL "$SCRIPT" 2 "rl:{u42}:$w" "rl:{u42}:$((w - 1))" "$1" 60000 100 | paste -sd' ' -
}
for i in $(seq 100); do check 1000 > /dev/null; done
echo "101st request at t=1s:  $(check 1000)"
echo "first request at t=90s: $(check 90000)"
ok=0; for i in $(seq 60); do [[ $(check 90000) == 1* ]] && ok=$((ok + 1)); done
echo "60 more at t=90s, allowed: $ok"
echo "TTL of window 1 (ms): $(redis-cli PTTL 'rl:{u42}:1')"
Result
101st request at t=1s:  0 0
first request at t=90s: 1 49
60 more at t=90s, allowed: 49
TTL of window 1 (ms): 119974
What’s happening
  1. The caller passes both keys (this window's and the previous one's) as KEYS, not built inside the script: Redis Cluster routes a script by its declared keys, and the {u42} hash tag guarantees both live on the same shard. Window 0 covers 0–60 s, window 1 covers 60–120 s.
  2. The first 100 calls at t = 1 s find previous = 0 and current going from 0 to 99, so each is allowed and INCR takes the counter to 100. The 101st sees an estimate of 100, which is not below the limit: it returns 0 0 (denied, 0 remaining) and changes nothing, so rejected requests don't inflate the count.
  3. At t = 90 s the window is 1. overlap = 1 - 30000/60000 = 0.5, so the previous window's 100 requests count as 50 and the caller has 50 requests of room: the first returns {1, 49}, and of 60 more only 49 are allowed. Over 60 → 120 s the weight keeps shrinking, so room returns smoothly instead of all at once at a window boundary.
  4. PEXPIRE makes each counter expire two windows (120,000 ms) after its last write; the TTL read back a moment later is 119,974 ms. The store never needs a cleanup job.
  5. The script makes one round trip per request, which is exactly what the estimates priced at 1 million calls a second. That's why it's reserved for the rules that need it.
AdvancedDeep dive: local counts with sync

The model at the top is this design's default path. Three refinements make it production-grade:

  • Sync what changed, read back what you use. A gateway only syncs keys it saw since the last sync, so a gateway serving 5,000 distinct callers a second sends a 5,000-command pipeline once a second, not 5 million.
  • Split the limit for very hot keys. For a caller sending 20,000 requests a second, even a 1-second staleness lets through thousands of extra requests. A gateway can give itself a local share (limit / gateways, adjusted by how much of that caller's traffic it actually sees) and refuse locally once the share is used, without waiting for the sync.
  • Cache rejections. Once a caller is over a fixed one-second limit, the gateway knows they will stay over for the rest of that second; it rejects them from memory without evaluating the rules again. That saves gateway CPU. The store cost is already capped by batching: each gateway sends at most one INCRBY per key per sync, however many requests that key made, which is what makes the attacker line in the estimates cost 200 ops/s rather than 20,000.
AdvancedDeep dive: failure modes
FailureWhat happensWhat to do
Counter store downGateways can't syncFail open: keep enforcing each gateway's local share (limit / gateways); exact security rules fail closed
Store slowExact-rule latency climbsShort timeout (a few ms) on the script call, then the fail mode
Gateway restartsIts local pending counts are lostHarmless: at most one sync interval of counts per restart
Clock skew between gatewaysWindow boundaries disagree by the skewUse the window number from one clock (the store's TIME or a synced clock); skew of milliseconds doesn't matter for second-long windows
Rules store downNo rule changesGateways keep their cached rules
Multi-regionOne global count needs cross-region callsGive each region its share of the limit, or accept per-region limits; never put a cross-region round trip on the request path
AdvancedDeep dive: which algorithm per rule
RuleAlgorithmWhy
Burst (20/s)Token bucketAllows short bursts up to the bucket size, smooth refill; two numbers per key
Per-minute API quotaSliding-window counterNo boundary doubling, two counters per key, cheap to sync
Daily quota (10,000/day)Fixed windowA boundary burst doesn't matter at day scale; one counter
Login attempts (5 per 15 min)Sliding log (timestamps)Small numbers, must be exact; a sorted set per key
Outbound calls to a partner (300/min)Leaky bucket (a queue)Smooths your own traffic to what they accept, instead of rejecting

Rate limiting algorithms runs all of these side by side and shows the fixed window's boundary burst.

AdvancedRate limiter trade-offs

Trade-offs and what to say

  • Accuracy vs cost: the sync interval is the dial. Name it, and say which rules get which setting.
  • Fail open vs fail closed: an outage of the limiter must not become an outage of the API, except where allowing is the bigger risk.
  • Where to run it: gateway (central, consistent, one place to configure) vs in each service (closer to the business rules, e.g. "per tenant per report"). Many systems do both: coarse limits at the gateway, fine ones in the service.
  • What the client sees matters as much as the algorithm: Retry-After stops well-behaved clients from hammering you, and clients should retry with jittered backoff (see Retries, dead-letter queues and poison messages).
Four gateways, limit 100 per minute, each gateway enforces 25

A colleague suggests skipping the shared store entirely: with 4 gateways, each one enforces a quarter of the limit locally. When does that break?

Show answer

It works only if the load balancer spreads each caller's requests evenly over the gateways, and it breaks in both directions when it doesn't.

What’s happening
  1. With perfect round-robin, a caller sending 50 requests a second gives each gateway 12.5 a second; each admits 25 a minute, so the caller gets exactly 100. This is the case the colleague has in mind.
  2. With sticky routing (consistent hashing on the client IP or connection reuse, which HTTP/2 and keep-alive make common), one gateway sees all of a caller's requests. It admits 25 a minute and rejects the rest: the caller is limited to 25, a quarter of what they paid for.
  3. With a caller whose connections land unevenly (say 70% on one gateway), the busy gateway runs out early while the others have unused allowance: the caller is wrongly rejected even though the global total is under 100.
  4. When gateways scale from 4 to 8, every local share must be recomputed or the effective limit silently doubles.
  5. That's why the design keeps local counting but syncs to a shared total: the local share is only the fallback for when the store is unreachable, where being somewhat wrong is better than being down.

#Design a distributed key-value store

Added

A key-value store does put(key, value) and get(key) and nothing else, and that small surface is what lets it scale to tens of terabytes and hundreds of thousands of operations a second. The interview prompt ("design something like Dynamo, Cassandra or Redis Cluster") is really a tour of Distributed Systems Essentials: how do you split the keys across machines, keep copies so a dead machine loses nothing, and decide what a read returns when the copies disagree?

The mental model is a library with many branches. Every book is shelved in three branches, chosen by hashing its title onto a circle of branches and taking the next three going clockwise. To borrow a book you don't need all three branches to answer, just a majority, and to return one you don't need all three to accept it. If a branch is closed for the day, a neighbouring branch keeps the book with a sticky note saying "this belongs to the branch on Elm Street", and drops it off when Elm Street reopens. The sticky note is hinted handoff; "a majority" is a quorum.

Before designing, make the one choice that shapes everything else and say it out loud. A store built on consensus (Raft, as in etcd or TiKV) gives linearizable reads and writes but stops accepting writes for a key range when it can't reach a majority of that range's replicas. A Dynamo-style leaderless store keeps accepting writes during failures and resolves disagreements afterwards. This design takes the Dynamo-style, always-writable path because the requirements below ask for it (a shopping cart that must accept "add to cart" during an outage); the trade-offs section says when you'd pick the other one.

Here's the core of it, small enough to run: five nodes on a hash ring, three replicas per key, quorum writes and reads, and a node failing and coming back.

kv-quorum.mjs
import { createHash } from "node:crypto";
// A Dynamo-style store: 5 nodes on a hash ring (8 virtual nodes each), N=3 replicas, W=2, R=2,
// sloppy quorum with hinted handoff. Versions are a simple counter per key (real systems: vector clocks).
const N = 3, W = 2, R = 2;
const h = (s) => parseInt(createHash("md5").update(s).digest("hex").slice(0, 8), 16);
const nodes = Object.fromEntries(["A", "B", "C", "D", "E"].map((n) => [n, { up: true, data: new Map(), hints: [] }]));
const ring = Object.keys(nodes).flatMap((n) => Array.from({ length: 8 }, (_, v) => ({ pos: h(`${n}#${v}`), node: n })))
  .sort((a, b) => a.pos - b.pos);

// Walk clockwise from the key's position and collect distinct physical nodes.
function walk(key) {
  const start = ring.findIndex((p) => p.pos >= h(key));
  const out = [];
  for (let i = 0; out.length < ring.length && i < ring.length; i++) {
    const n = ring[(Math.max(start, 0) + i) % ring.length].node;
    if (!out.includes(n)) out.push(n);
  }
  return out; // first N are the "home" replicas; the rest are stand-ins
}

function put(key, value, version) {
  const order = walk(key), home = order.slice(0, N), stand = order.slice(N).filter((n) => nodes[n].up);
  const acks = [];
  for (const n of home) {
    if (nodes[n].up) { nodes[n].data.set(key, { value, version }); acks.push(n); continue; }
    const s = stand.shift(); // home replica is down: write to the next healthy node, with a hint
    nodes[s].data.set(key, { value, version });
    nodes[s].hints.push({ for: n, key, value, version });
    acks.push(`${s}(hint for ${n})`);
  }
  console.log(`put ${key}=${value} v${version} -> ${acks.join(", ")}  ${acks.length >= W ? "OK" : "FAILED"}`);
}

function get(key) {
  const replies = walk(key).slice(0, N).filter((n) => nodes[n].up).slice(0, R)
    .map((n) => ({ n, ...(nodes[n].data.get(key) ?? { value: null, version: 0 }) }));
  if (replies.length < R) return console.log(`get ${key}: only ${replies.length} replies, FAILED`);
  const newest = replies.reduce((a, b) => (b.version > a.version ? b : a));
  console.log(`get ${key} <- ${replies.map((r) => `${r.n}:v${r.version}`).join(" ")} => ${newest.value}`);
}

function handOff() {
  for (const [name, node] of Object.entries(nodes)) {
    node.hints = node.hints.filter((hint) => {
      if (!nodes[hint.for].up) return true; // keep it until the owner is back
      nodes[hint.for].data.set(hint.key, { value: hint.value, version: hint.version });
      node.data.delete(hint.key); // the stand-in drops its temporary copy
      console.log(`hint ${name} -> ${hint.for}: ${hint.key} v${hint.version} delivered`);
      return false;
    });
  }
}

const key = "cart:42";
console.log(`home replicas for ${key}: ${walk(key).slice(0, N).join(", ")}`);
put(key, "1 book", 1);
nodes[walk(key)[1]].up = false;
console.log(`-- ${walk(key)[1]} goes down`);
put(key, "2 books", 2);
get(key);
nodes[walk(key)[1]].up = true;
console.log(`-- ${walk(key)[1]} is back`);
handOff();
get(key);
const load = Object.fromEntries(Object.keys(nodes).map((n) => [n, 0]));
for (let i = 0; i < 30_000; i++) for (const n of walk(`k${i}`).slice(0, N)) load[n]++;
console.log("replicas held per node (30,000 keys x 3):", JSON.stringify(load));
Result
home replicas for cart:42: B, A, C
put cart:42=1 book v1 -> B, A, C  OK
-- A goes down
put cart:42=2 books v2 -> B, D(hint for A), C  OK
get cart:42 <- B:v2 C:v2 => 2 books
-- A is back
hint D -> A: cart:42 v2 delivered
get cart:42 <- B:v2 A:v2 => 2 books
replicas held per node (30,000 keys x 3): {"A":16010,"B":18083,"C":14750,"D":22180,"E":18977}
What’s happening
  1. Each of the 5 nodes gets 8 positions on the ring (A#0 … A#7, hashed with MD5), 40 positions in all. walk(key) hashes the key onto the ring and walks clockwise, collecting distinct physical nodes: for cart:42 that's B, A, C, then the rest. The first N = 3 are the key's home replicas (its preference list); the others are stand-ins. This is Consistent hashing with virtual nodes.
  2. The first put writes version 1 to B, A and C. All three acknowledge; it needs only W = 2, so it would already have answered after the second ack.
  3. Node A goes down. The second put (version 2) finds A unreachable and does a sloppy quorum: it writes to the next healthy node on the walk, D, along with a hint "this belongs to A". B, D and C acknowledge, so the write succeeds even though one home replica is down. A strict quorum (home replicas only) would still have succeeded here with 2 of 3, but with two home replicas down only the sloppy version keeps accepting writes.
  4. The get with R = 2 asks the healthy home replicas and gets two replies, B and C, both at version 2, so it returns 2 books. Had one reply been version 1, the coordinator would pick the higher version and could write it back to the stale replica (read repair).
  5. When A comes back, handOff runs: D delivers cart:42 v2 to A and deletes its temporary copy. The next get reads B and A, both at version 2. Without the hint, A would have kept version 1 until a read repair or a background anti-entropy pass (Merkle trees, below) fixed it.
  6. The last line is the cost of only 8 virtual nodes: replica counts per node range from 14,750 to 22,180 against a fair share of 18,000, so node D does 23% more work than average and C 18% less. More virtual nodes, or placing them with an allocation algorithm instead of at random (Cassandra 4.0 defaults to 16 tokens per node, placed that way), narrow that spread.
AdvancedKey-value store requirements

Requirements

Functional:

  • put(key, value), get(key), delete(key). Keys up to a few hundred bytes, values up to about 1 MB (larger blobs belong in object storage).
  • No transactions across keys, no range scans (a sorted, range-partitioned store is a different design).

Non-functional:

  • Always writable: a write must succeed as long as any few nodes are up, even during network partitions. Conflicts are acceptable and resolved later.
  • Tunable consistency per request: the caller can ask for stronger reads (R = N) when it needs them.
  • Low, predictable latency: p99 single-digit milliseconds within a region. The Dynamo paper (Amazon, SOSP 2007) framed its targets the same way, as a 99.9th-percentile SLA, giving the example of answering within 300 ms for 99.9% of requests at 500 requests a second.
  • Durable: three replicas, no single point of failure, survive a whole node or rack.
  • Incremental scale: add a node and only its share of keys moves.
  • Scale: 10 billion keys, about 1 KB each, 300,000 reads and 100,000 writes a second at peak.
AdvancedKey-value store estimates in code

Estimates

Assumptions: 10 billion items of about 1 KB (key plus value plus metadata); N = 3, W = 2, R = 2; nodes with 4 TB of SSD filled to at most 50% (an LSM store needs free space for compaction, and survivors must absorb a dead node's data); 300k reads and 100k writes a second at peak; about 20,000 replica operations a second per node (a rule of thumb for an SSD-backed LSM node; measure yours).

kv-estimates.mjs
// Assumptions (stated in the text): 10 billion keys, 1 KB average value + key, N=3 replicas,
// nodes with 4 TB of SSD filled to at most 50% (room for compaction and for taking over a dead node's data),
// 300k reads/s and 100k writes/s at peak, W=2 and R=2, ~20k replica operations/s per node (rule of thumb).
const keys = 10e9, bytesPerItem = 1_000, N = 3, W = 2, R = 2;
const diskTB = 4, fill = 0.5, peakReads = 300_000, peakWrites = 100_000, opsPerNode = 20_000;

const rawTB = (keys * bytesPerItem) / 1e12;
const storedTB = rawTB * N;
const nodesForDisk = Math.ceil(storedTB / (diskTB * fill));
// Every write lands on all N replicas (W only decides how many we wait for). Every read is also sent
// to all N (the coordinator answers from the first R replies), so it costs N replica reads, not R.
const replicaOps = peakWrites * N + peakReads * N;
const nodesForOps = Math.ceil(replicaOps / opsPerNode);

console.log(`data: ${rawTB} TB raw, ${storedTB} TB with ${N} replicas`);
console.log(`nodes for disk: ${nodesForDisk} (${diskTB} TB each, filled to ${fill * 100}%)`);
console.log(`replica operations at peak: ${replicaOps.toLocaleString("en-US")}/s -> ${nodesForOps} nodes`);
const nodes = Math.max(nodesForDisk, nodesForOps);
console.log(`cluster: ${nodes} nodes; each holds ~${((storedTB / nodes) * 1000).toFixed(0)} GB`);
// When one node dies, its data is re-replicated from the others. Best case: every peer sends at once
// with 200 MB/s to spare, to many receivers. Then with each sender throttled to 24 MiB/s.
const perNodeGB = (storedTB / nodes) * 1000;
const rebuildMin = perNodeGB / (0.2 * (nodes - 1)) / 60;
console.log(`rebuild of one dead node, best case, ${nodes - 1} peers at 200 MB/s each: ~${rebuildMin.toFixed(1)} min`);
const throttledMin = perNodeGB / (((24 * 2 ** 20) / 1e9) * (nodes - 1)) / 60;
console.log(`same rebuild, each peer throttled to 24 MiB/s: ~${throttledMin.toFixed(1)} min`);
Result
data: 10 TB raw, 30 TB with 3 replicas
nodes for disk: 15 (4 TB each, filled to 50%)
replica operations at peak: 1,200,000/s -> 60 nodes
cluster: 60 nodes; each holds ~500 GB
rebuild of one dead node, best case, 59 peers at 200 MB/s each: ~0.7 min
same rebuild, each peer throttled to 24 MiB/s: ~5.6 min
What’s happening
  1. 10 billion × 1 KB is 10 TB of data, 30 TB stored with three replicas.
  2. Disk alone needs 15 nodes: 30 TB over 2 TB of usable space per node.
  3. Throughput needs more. W = 2 only decides how many acks the client waits for; every write still goes to all 3 replicas. Reads work the same way in this design: the coordinator sends each read to all 3 replicas and answers from the first two replies (the tail-latency trick in the quorum deep dive), so R = 2 decides the wait, not the work. So 100k writes are 300k replica writes, and 300k reads are 900k replica reads: 1,200,000 replica operations a second, which is 60 nodes at 20k each. (Sending reads to only two replicas would make it 900k operations and 45 nodes, at the cost of the tail latency the deep dive measures; a middle ground is to read two replicas in full and send the third only a digest request, which returns a hash of the value, or a speculative request once one of the two is slow.)
  4. Throughput wins, so the cluster is 60 nodes, each holding about 500 GB, a quarter of its usable disk. That's typical: hot key-value clusters are usually sized by operations, and the spare disk becomes headroom.
  5. Losing a node means re-creating its 500 GB from the other replicas. Because virtual nodes spread a node's ranges over every other node, all 59 peers help: at 200 MB/s each that's about 0.7 minutes, instead of hours if one partner had to copy everything. That's the operational argument for virtual nodes, as strong as the balance argument.
  6. Treat 0.7 minutes as the best case: it assumes every peer has 200 MB/s to spare and that the data flows to many receivers at once. Operators throttle streaming so a rebuild doesn't starve live traffic: Cassandra's stream_throughput_outbound defaults to 24 MiB/s per sending node (its cassandra.yaml docs), and at that rate the same rebuild takes about 5.6 minutes. And when one replacement node receives all 500 GB, its own disk and network cap the speed, whatever the senders can do. Real rebuilds take longer than the best case; the parallelism is still what keeps them in minutes rather than hours.
AdvancedKey-value store API sketch

API

Client API (Dynamo-style)
get(key)                       -> { values: [v1, v2?], context }   more than one value = concurrent writes to resolve
put(key, value, context)       -> ok | error                         context = the version vector from the get
delete(key, context)           -> ok                                  writes a tombstone, not an erase
options: { r: 1..N, w: 1..N, timeoutMs }                               per-request consistency

The context is the important part. A client reads, changes, and writes back with the version it read, so the store can tell "this write replaces version X" from "this write happened alongside version X without knowing about it". When it's the second case, a later get returns both values and the client (or a merge rule) resolves them. For a shopping cart, the merge is a union of items.

AdvancedKey-value store data model

Data model

On each node, every key maps to (value, version vector, tombstone?), stored in a log-structured merge tree, the storage engine of Cassandra, ScyllaDB and RocksDB:

  • Commit log (append-only): a write is durable on this node once the log is fsync'd. In this design each replica fsyncs before it acks, batching many writes per fsync (group commit). Cassandra's default is weaker and worth knowing: commitlog_sync: periodic acks writes at once and fsyncs every 10 s (its cassandra.yaml docs), relying on the other replicas to cover a crash in that window.
  • Memtable (sorted, in memory): the write is applied here and acknowledged.
  • SSTables (sorted, immutable files): the memtable is flushed when full; background compaction merges SSTables and drops overwritten values and expired tombstones.
  • A Bloom filter per SSTable lets a read skip files that can't contain the key, which is why point reads stay fast even with many SSTables (see Bloom filters).

Cluster metadata is small: the ring (which node owns which token ranges), the membership list with each node's state, and the per-keyspace replication settings. Nodes spread it to each other by gossip: every second each node exchanges state with a few random peers, so a change reaches all 60 nodes in a handful of rounds without a central coordinator.

AdvancedKey-value store architecture

High-level design

walk clockwise: first 3 nodes cart:42 B A down C D holds hint for A E Client Coordinator any node, here B B ack A no reply C ack D ack + hint N=3: B, A, C own the key W=2: answer after 2 acks A back: D hands the copy over
The ring decides who owns a key; the coordinator decides how many owners must answer. A down owner is covered by the next node on the ring, which keeps a hint.
  1. Any node can coordinate. The client (or a smart client library that knows the ring) sends the request to a node; ideally one of the key's replicas, to save a hop.
  2. Write: the coordinator sends the write to all N replicas in the preference list (stand-ins with hints for any that are down), and answers the client after W acknowledgements. Each replica appends to its commit log, updates its memtable, and acks.
  3. Read: the coordinator sends the read to all N replicas and uses the first R replies (the estimates count all three replica reads), compares versions, returns the newest (or all concurrent siblings), and repairs stale replicas in the background.
  4. Membership and failure detection run by gossip: each node tracks how recently it heard from each peer and marks a peer as down when it has been silent for too long relative to its usual rhythm (Cassandra uses a phi-accrual detector for this). Being marked down only affects routing; a node is removed from the ring only by an operator, because a long pause and a dead machine look the same from outside.
  5. Background repair (Merkle-tree anti-entropy) catches whatever hints and read repair missed.
AdvancedDeep dive: partitioning and replication

Hashing keys onto a ring with virtual nodes gives three properties at once, and an interviewer will want all three named:

  • Incremental scaling: a new node takes over a slice of each existing node's ranges, so only about 1/(nodes + 1) of the keys move. Consistent hashing measures this movement against hash mod N, where almost every key moves.
  • Balance: more positions per node evens out the load. The model's 8 positions gave a spread from 14,750 to 22,180 replicas per node; Cassandra 4.0 defaults to 16 tokens per node plus an allocation algorithm that places them for balance (allocate_tokens_for_local_replication_factor, default 3) instead of at random.
  • Fast rebuilds: a dead node's ranges are scattered over all its peers, so all of them stream data in parallel (0.7 minutes in the estimates at best, about 5.6 with Cassandra's default streaming throttle).

Replication adds one rule: the preference list skips positions belonging to a node already in the list, and with racks or availability zones, it also skips nodes in a rack already used, so the three copies are in three failure domains. Losing a whole rack then costs one replica of each key, never all three.

AdvancedDeep dive: quorums and tail latency

R + W > N guarantees that every read set overlaps every write set in at least one replica, so a read sees the latest acknowledged write (as long as membership doesn't change mid-way and the hinted stand-ins aren't counted, see the gotcha below). Quorums: R + W > N simulates stale reads for each setting. Here the question is the other cost of quorums: latency. A quorum operation waits for the R-th fastest replica, so a single slow replica hurts more as R grows:

quorum-latency.mjs
// Why (N,R,W) = (3,2,2) and not R=3? A quorum read waits for the R-th fastest of N replicas.
// Each replica's latency: 1 ms typical, with a 2% chance of a 40 ms stall (GC pause, compaction, busy disk).
// Seeded PRNG (mulberry32) so the output is reproducible.
function mulberry32(a) {
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(7);
const replicaLatency = () => (rand() < 0.02 ? 40 : 0.6 + rand() * 0.8);
const pct = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];

const TRIALS = 200_000;
for (const r of [1, 2, 3]) {
  const samples = [];
  for (let i = 0; i < TRIALS; i++) {
    const three = [replicaLatency(), replicaLatency(), replicaLatency()].sort((a, b) => a - b);
    samples.push(three[r - 1]); // the coordinator answers once the R-th reply arrives
  }
  samples.sort((a, b) => a - b);
  const slow = samples.filter((x) => x >= 40).length / TRIALS;
  console.log(`R=${r}: p50 ${pct(samples, 0.5).toFixed(2)} ms, p99 ${pct(samples, 0.99).toFixed(2)} ms, p99.9 ${pct(samples, 0.999).toFixed(2)} ms, stalled ${(slow * 100).toFixed(2)}%`);
}
Result
R=1: p50 0.77 ms, p99 1.24 ms, p99.9 1.34 ms, stalled 0.00%
R=2: p50 1.01 ms, p99 1.37 ms, p99.9 40.00 ms, stalled 0.11%
R=3: p50 1.25 ms, p99 40.00 ms, p99.9 40.00 ms, stalled 5.84%
What’s happening
  1. Each replica answers in 0.6–1.4 ms, except that 2% of the time it stalls for 40 ms: a garbage-collection pause, a compaction burst or a busy disk. These numbers are invented to show the shape; the PRNG is seeded so they're stable.
  2. A quorum read sends to all 3 replicas and answers when the R-th reply arrives, so its latency is the R-th smallest of three draws.
  3. R = 1 stalls only if all three replicas stall at once (0.02³ = 0.0008%), so its p99.9 stays at 1.34 ms. R = 2 stalls when at least two stall: 0.11% of requests, which is under 1%, so p99 stays at 1.37 ms, but over 0.1%, so p99.9 becomes 40 ms.
  4. R = 3 waits for the slowest replica, so any single stall shows: 5.84% of requests stall (1 − 0.98³), and even p99 is 40 ms. Asking every replica turns each replica's bad moments into your bad moments.
  5. This is why the Dynamo paper reports (N, R, W) = (3, 2, 2) as the common configuration across its instances: R + W = 4 > 3 gives the overlap, and waiting for 2 of 3 hides one slow replica. Going to R = 3 buys nothing in consistency that R = 2 didn't already give with W = 2, and costs the tail.

Two latency tricks follow directly from this: send the read to all three replicas and use the first two replies (rather than picking two and hoping; it costs a third replica read per read, which the estimates count), and for R = 1 reads, send a second "hedged" request if the first hasn't answered by the p95 time.

AdvancedDeep dive: failures, temporary and permanent
FailureMechanismHow it works
A node is down for minutesHinted handoffA stand-in keeps the write with a hint and delivers it when the owner returns (the model)
A replica missed a write and someone reads itRead repairThe coordinator sees an older version in the replies and writes the newer one back
A node was down for hours, or hints were lostAnti-entropyReplicas compare Merkle trees of their key ranges and exchange only the ranges whose hashes differ
A node is gone for goodReplace itAn operator removes it; its ranges stream to their new owners from the surviving replicas

Merkle trees are what make anti-entropy affordable: two replicas holding 500 GB each compare a tree of hashes top-down and only descend into subtrees whose hashes differ, so a handful of differing keys costs a handful of hash comparisons per tree level instead of a full scan. Merkle trees builds one and finds the differing leaf.

Deletes need care. A delete writes a tombstone (a "deleted at version X" marker), not an erase, because an erased key on one replica looks exactly like a key that replica never received, and repair would copy the old value back. Tombstones are kept for a grace period and then dropped by compaction; in Cassandra that's gc_grace_seconds, 864,000 seconds (10 days) by default, and its docs warn that a node down for longer than that can bring deleted data back. That's why repairs must run more often than the grace period.

AdvancedDeep dive: conflicting versions

With concurrent writes to different replicas (two browser tabs updating the same cart during a partition), replicas end up with versions that neither supersedes. There are two families of answer:

  • Last-write-wins (LWW): every write carries a timestamp and the highest wins. Simple, and what Cassandra does per column. The cost is silent data loss: of two concurrent cart updates, one disappears, and clock skew can make an older write win.
  • Version vectors: each value carries a vector of per-node counters. If one vector is greater than or equal to the other in every entry, it supersedes it; otherwise the two are concurrent and both are kept as siblings for the client to merge. Dynamo's shopping cart merged siblings by union, which occasionally brought a deleted item back, which Amazon judged better than losing an added one. Time and ordering: Lamport and vector clocks runs the comparison.
  • CRDTs are the third option for specific data types: counters, sets and maps whose merge is defined to always converge, so there are no siblings to resolve by hand (see Conflict resolution: last-write-wins and CRDTs).

For an interview, say which one you pick for the use case and what it costs: "LWW for user profiles, where losing one of two concurrent edits is acceptable; siblings merged by union for the cart, where losing an add is not".

AdvancedKey-value store trade-offs

Trade-offs and what to say

  • AP vs CP. This design stays writable during partitions and pays with conflicts and eventual consistency. If the requirement is "a read must always see the latest write" (configuration, locks, account balances), use a consensus-based store instead: each key range is a Raft group with a leader, writes are linearizable, and a range without a majority stops taking writes (see CAP and PACELC in a design discussion and Leader election and consensus).
  • Tunable consistency is the escape hatch: R = W = 2 by default, W = 1 for fire-and-forget metrics, R = 3 for the rare read that must see everything.
  • LSM vs B-tree: LSM makes writes sequential and cheap and reads pay with merging across SSTables; a B-tree engine favours reads. A write-heavy, always-writable store is the textbook LSM case.
  • What you'd monitor: hint backlog, read-repair rate, pending compactions, p99 per replica, and tombstone counts per read.
Which (N, R, W) for a write-heavy metrics store?

A metrics service writes a million data points a second and reads them only for dashboards, where a few seconds of staleness is fine. Losing a data point now and then is acceptable. What (N, R, W) would you pick, and what do you give up?

Show answer

N = 3, W = 1, R = 1 (or R = 2 for dashboards that must not flicker), accepting that a fresh write may be missing from a read and that a write acked by one replica can be lost if that replica dies before replicating.

What’s happening
  1. Writes dominate, so the write path must be as cheap and as available as possible. W = 1 answers after the first ack: the latency is the fastest replica's, and a write succeeds as long as any one replica (or stand-in) is up.
  2. Every write still goes to all three replicas in the background, so the data does end up in three places; W only changes how long the client waits and how many copies are guaranteed at ack time.
  3. R + W = 2, which is not greater than N = 3, so a read may hit two replicas that haven't received the newest point yet. For a dashboard refreshed every few seconds, that's a point appearing one refresh late.
  4. The real risk is durability: if the one replica that acked dies before the write reaches the others, that point is gone. The requirements say that's acceptable for metrics; for the shopping cart it wouldn't be, which is why the cart uses W = 2.
  5. The general rule to say: W trades write latency and availability against durability at ack time; R trades read latency against freshness; R + W > N is the line where reads are guaranteed to overlap writes.

#Design a web crawler

Added

A web crawler starts from a set of seed URLs, downloads each page, extracts the links, and adds the new ones to its to-do list, over and over, until it has fetched the part of the web it was asked for. Search engines, archives (Common Crawl, the Internet Archive), price monitors and AI training pipelines all run one. The loop itself is ten lines of code; the design question is everything that loop does wrong at scale: it hammers small websites, fetches the same page a thousand times under different URLs, falls into infinite calendars, and can't decide what to fetch next out of billions of candidates.

The mental model is a very polite, very busy librarian with a reading list. The list (the URL frontier) is ordered by importance, but the librarian never knocks on the same door twice in a short time, however important the next book from that house is: they read from other houses meanwhile. Before adding any title to the list they check a notebook of titles already seen (URL dedup, a Bloom filter at this scale), and after reading a book they check whether they've read the same text under a different title (content dedup). And they respect each house's sign on the door (robots.txt).

The frontier is the heart of the design, because it's where politeness and priority meet. Here it is as a small model: one queue per host, the host with the earliest allowed fetch time goes next, a one-second delay per host, and a Bloom filter deciding what's new.

frontier.mjs
// URL frontier: one FIFO queue per host, a min-heap-like pick of the host whose next allowed fetch time
// is earliest, a 1 s politeness delay per host, and a Bloom filter for "have we seen this URL?".
// The "web" is fake and deterministic: page p on host h links to 3 pages chosen by a seeded PRNG.
function mulberry32(a) {
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(1);
const HOSTS = ["news.example", "shop.example", "blog.example", "wiki.example"];
const linksOf = () => Array.from({ length: 3 }, () => `https://${HOSTS[Math.floor(rand() * HOSTS.length)]}/p${Math.floor(rand() * 400)}`);

// Bloom filter: m bits, k probes from two FNV-1a hashes (double hashing: h1 + i*h2).
class Bloom {
  constructor(m, k) { this.m = m; this.k = k; this.bits = new Uint8Array(Math.ceil(m / 8)); }
  #hashes(s) {
    let a = 0x811c9dc5, b = 0x01000193 ^ 0x5bd1e995;
    for (const c of s) { a = Math.imul(a ^ c.charCodeAt(0), 0x01000193); b = Math.imul(b ^ c.charCodeAt(0), 0x5bd1e995); }
    return Array.from({ length: this.k }, (_, i) => ((a >>> 0) + i * ((b | 1) >>> 0)) % this.m);
  }
  add(s) { for (const i of this.#hashes(s)) this.bits[i >> 3] |= 1 << (i & 7); }
  has(s) { return this.#hashes(s).every((i) => this.bits[i >> 3] & (1 << (i & 7))); }
}

const DELAY = 1000;
const seen = new Bloom(8_000, 4); // deliberately small (~5 bits per URL) so false positives show up
const truth = new Set(); // exact set, only to measure what the Bloom filter got wrong
const hostQueues = new Map(); // host -> { queue: [], nextAt }
let dupes = 0, wronglySkipped = 0;

function enqueue(url) {
  if (seen.has(url)) { truth.has(url) ? dupes++ : wronglySkipped++; return; }
  seen.add(url); truth.add(url);
  const host = new URL(url).host;
  if (!hostQueues.has(host)) hostQueues.set(host, { queue: [], nextAt: 0 });
  hostQueues.get(host).queue.push(url);
}

HOSTS.forEach((h) => enqueue(`https://${h}/`));
let clock = 0, fetched = 0;
const log = [];
while (true) {
  // Pick the host whose next allowed fetch is soonest (a heap keyed by nextAt in a real crawler).
  const ready = [...hostQueues.entries()].filter(([, q]) => q.queue.length).sort((a, b) => a[1].nextAt - b[1].nextAt);
  if (!ready.length) break;
  const [host, q] = ready[0];
  clock = Math.max(clock, q.nextAt); // wait if even the soonest host isn't allowed yet
  const url = q.queue.shift();
  q.nextAt = clock + DELAY; // politeness: no second request to this host for 1 s
  if (log.length < 8) log.push(`t=${String(clock).padStart(4)} ms  ${url}`);
  fetched++;
  linksOf().forEach(enqueue);
}
console.log(log.join("\n"));
console.log(`fetched ${fetched} pages in ${clock / 1000} s of crawl time (4 hosts x 1 page/s)`);
const fresh = truth.size + wronglySkipped;
console.log(`links to already-seen URLs dropped: ${dupes}`);
console.log(`new URLs: ${fresh}, wrongly dropped as "seen" (false positives): ${wronglySkipped} (${(wronglySkipped / fresh * 100).toFixed(1)}%)`);
Result
t=   0 ms  https://news.example/
t=   0 ms  https://shop.example/
t=   0 ms  https://blog.example/
t=   0 ms  https://wiki.example/
t=1000 ms  https://news.example/p161
t=1000 ms  https://shop.example/p397
t=1000 ms  https://blog.example/p1
t=1000 ms  https://wiki.example/p112
fetched 1471 pages in 374 s of crawl time (4 hosts x 1 page/s)
links to already-seen URLs dropped: 2879
new URLs: 1538, wrongly dropped as "seen" (false positives): 67 (4.4%)
What’s happening
  1. The fake web has 4 hosts with up to 400 pages each; every fetched page links to 3 pages chosen by a seeded PRNG, so the crawl is the same on every run. The frontier starts with the 4 home pages.
  2. hostQueues holds one FIFO queue per host plus nextAt, the earliest time that host may be fetched again. Each step picks the host with the smallest nextAt that has work (a real crawler keeps those hosts in a min-heap; sorting is fine for 4). After a fetch, nextAt = clock + 1000.
  3. The first 8 fetches show politeness at work: all four home pages go at t = 0 because they're on different hosts, and the next page from each host waits until t = 1000 ms, even though news.example had more pages queued. Parallelism comes from many hosts, never from hitting one host harder. The whole crawl took 374 s for 1,471 pages: four hosts can't go faster than 4 pages a second, which is exactly the constraint the estimates turn into "how many hosts must be in flight".
  4. Every extracted link goes through enqueue, which asks the Bloom filter "seen before?". Of the 4,417 URLs offered to the frontier (4 seeds plus 3 links from each of 1,471 pages), 2,879 pointed to URLs already seen and were dropped without touching any queue: on the real web most links point to pages you already know, so this check runs far more often than fetches do.
  5. The filter is deliberately undersized (8,000 bits for about 1,500 URLs, ~5 bits each), and it shows the one way a Bloom filter fails: 67 of 1,538 genuinely new URLs (4.4%) were wrongly reported as seen and never crawled. It never does the opposite (a URL it says is new is always new), so the crawler never fetches a page twice because of it. The exact Set is there only to measure the error; at the real scale, the estimates below size the filter for 1%.
  6. That's the whole frontier idea: a priority order across hosts, a strict rate within each host, and a cheap probabilistic "seen" check in front of everything.
AdvancedWeb crawler requirements

Requirements

Ask what the crawler is for, because it changes the priorities: a search engine wants fresh, important pages; an archive wants breadth; a price monitor wants a few sites very often. This design assumes a general-purpose crawler for a search index.

Functional:

  • Start from seed URLs; fetch HTML pages; extract links; follow them within a configured scope (the whole web, or a list of domains).
  • Store the raw pages for the indexing pipeline downstream.
  • Recrawl pages so the stored copies stay fresh, more often for pages that change often or matter more.

Non-functional:

  • Politeness: obey robots.txt (RFC 9309, 2022) and never send a host more than about one request a second unless it's known to handle more.
  • Scale: 1 billion pages a month.
  • Robustness: malformed HTML, slow or hostile servers, redirect loops and crawler traps must not stall or crash the crawl.
  • Dedup: don't fetch the same URL twice in a cycle, don't store the same content twice.
  • Extensible: new content types (PDF, images) plug in as new processors.

Out of scope unless asked: executing JavaScript to render single-page apps (a separate, much more expensive rendering tier), ranking and the index itself.

AdvancedWeb crawler estimates in code

Estimates

Assumptions: 1 billion pages a month; the average stored page size taken from Common Crawl's August 2026 crawl, which its release notes describe as 2.14 billion pages and 360 TiB of uncompressed content; a 1-second delay per host; 10 billion distinct URLs seen over the crawler's life; a Bloom filter at 1% false positives; 30 links extracted per page.

crawler-estimates.mjs
// Assumptions (stated in the text): 1 billion pages a month; average stored page size taken from
// Common Crawl's August 2026 crawl (2.14 billion pages, 360 TiB uncompressed); 1 s politeness per host;
// 10 billion URLs ever seen; Bloom filter at 1% false positives; 30 links extracted per page.
const pagesPerMonth = 1e9, secondsPerMonth = 30 * 86_400;
const ccPages = 2.14e9, ccBytes = 360 * 2 ** 40;
const urlsSeen = 10e9, fpRate = 0.01, linksPerPage = 30;

const pagesPerSec = pagesPerMonth / secondsPerMonth;
const bytesPerPage = ccBytes / ccPages;
console.log(`fetch rate: ${pagesPerSec.toFixed(0)} pages/s average`);
console.log(`average page (Common Crawl): ${(bytesPerPage / 1e3).toFixed(0)} KB`);
console.log(`download: ${((pagesPerSec * bytesPerPage * 8) / 1e9).toFixed(2)} Gbit/s, storage ${((pagesPerMonth * bytesPerPage) / 1e12).toFixed(0)} TB a month (uncompressed)`);
// At 1 request per second per host, you need at least this many different hosts in flight at once.
console.log(`hosts in flight at 1 req/s each: at least ${Math.ceil(pagesPerSec)}`);
// URL dedup: 'have we seen it?' is asked for every extracted link.
console.log(`seen-URL checks: ${(pagesPerSec * linksPerPage).toFixed(0)}/s`);
// Bloom filter size: m = -n ln p / (ln 2)^2 bits, k = (m/n) ln 2 hash functions.
const bitsPerUrl = -Math.log(fpRate) / Math.LN2 ** 2;
console.log(`Bloom filter for ${urlsSeen / 1e9}B URLs at ${fpRate * 100}%: ${bitsPerUrl.toFixed(2)} bits each, ${((urlsSeen * bitsPerUrl) / 8 / 1e9).toFixed(1)} GB, k=${Math.round(bitsPerUrl * Math.LN2)}`);
// The exact alternative: a set of 8-byte URL fingerprints.
console.log(`exact set of 8-byte fingerprints: ${((urlsSeen * 8) / 1e9).toFixed(0)} GB`);
Result
fetch rate: 386 pages/s average
average page (Common Crawl): 185 KB
download: 0.57 Gbit/s, storage 185 TB a month (uncompressed)
hosts in flight at 1 req/s each: at least 386
seen-URL checks: 11574/s
Bloom filter for 10B URLs at 1%: 9.59 bits each, 12.0 GB, k=7
exact set of 8-byte fingerprints: 80 GB
What’s happening
  1. A billion pages over a 30-day month is 386 pages a second, on average. That is modest for a fleet: the challenge isn't raw throughput but doing it politely and without duplicates.
  2. Common Crawl's 360 TiB over 2.14 billion pages gives 185 KB per page (the stored record includes HTTP headers and the full uncompressed body). At 386 pages/s that's 0.57 Gbit/s of download and 185 TB a month before compression; compressed WARC files are several times smaller, but it's still an object-storage problem, not a database one.
  3. At one request per second per host, 386 pages a second means at least 386 hosts must be in flight at every moment. In practice far more, because fetches take time and many hosts have only a few pages queued. This is why the frontier is organised by host.
  4. Each page yields about 30 links, so the "seen?" check runs 11,574 times a second, 30 times more often than fetches. It must be an in-memory check, not a database query per link.
  5. A Bloom filter for 10 billion URLs at 1% needs 9.59 bits per URL (-ln p / (ln 2)²): 12 GB with k = 7 hash functions, which fits in memory, sharded over a few machines. An exact set of 8-byte URL fingerprints would be 80 GB, 6–7 times more. The price of the Bloom filter is the 1% of new URLs it wrongly skips, which Bloom filters measures against the formula; for a crawler, missing 1% of new pages is usually acceptable. Note that the miss belongs to the URL, not to the link: every later link to that URL hits the same set bits and is skipped too, so other links don't rescue it. It stays missed until the filter is rebuilt with fresh hash seeds from the URL store (which never recorded it), after which the next link to it gets through.
AdvancedWeb crawler API sketch

API

A crawler has no public request path. Its "API" is the control plane and the interfaces between components:

Control plane and internal interfaces
POST /crawls            { "seeds": ["https://news.example/"], "scope": { "domains": ["*.example"] },
                          "maxPages": 50000000, "recrawl": "adaptive" }        -> 201 { "crawlId": "c-17" }
GET  /crawls/c-17       -> { "fetched": 1203391, "queued": 8823004, "errors": { "5xx": 1210, "timeout": 377 } }

frontier.add(urls[], priority)          from the link extractor, after the URL filter and "seen?"
frontier.next(workerId) -> url | wait   to a fetcher: the next URL whose host is allowed now
frontier.done(url, status, nextDue)     after a fetch: record the outcome, schedule the recrawl
AdvancedWeb crawler data model

Data model

  • URL table (a wide-column or key-value store, keyed by a 64-bit hash of the normalised URL): url, host, priority, last_fetched, next_due, http_status, content_hash, change_count. About 10 billion rows; this is the durable record behind the in-memory Bloom filter.
  • Host table (small, cached in the frontier): host, robots.txt rules and when they were fetched, crawl delay, next_allowed_at, recent error rate, average response time.
  • Raw pages: WARC files in object storage (the web-archive format Common Crawl publishes in), with an index from URL hash to file and offset.
  • Content fingerprints: exact content hash and a SimHash per page, for duplicate and near-duplicate detection.
AdvancedWeb crawler architecture

High-level design

Seeds start URLs URL + host store last fetch, next due URL frontier priority queues one queue per host next allowed time Fetchers HTTP, timeouts robots + DNS cache Raw pages object store, WARC files Parser text, links, metadata Content seen? hash, SimHash URL filter normalise, scope, traps URL seen? Bloom filter links new recrawl
The crawl is a loop: frontier → fetch → parse → filter and dedup → frontier. Every stage after the fetch exists to keep junk and duplicates out of the frontier.
  1. Frontier → fetchers. A fetcher asks the frontier for work and gets a URL whose host is allowed now. Fetchers are many lightweight async workers (thousands of concurrent connections per machine), because most of a fetch is waiting on the network.
  2. Fetch. Resolve the host through a local DNS cache (an uncached DNS lookup per page would add tens of milliseconds and hammer resolvers), check the cached robots.txt rules, download with strict timeouts and a size cap, follow a bounded number of redirects.
  3. Store and parse. Write the raw response to a WARC file in object storage; parse the HTML for text, links and metadata (<link rel="canonical">, noindex, nofollow).
  4. Content dedup. If the exact content hash was seen before (mirrors, ?sessionid= variants), record the URL as a duplicate and stop; near-duplicates are found with SimHash.
  5. Link pipeline. Each link is resolved against the page URL, normalised, checked against scope and trap rules, then against "URL seen?". Survivors go to the frontier with a priority.
  6. Recrawl. The URL store schedules each known URL's next_due; due URLs re-enter the frontier.
  7. Distribution. Partition the frontier by host hash: every URL of a host goes to the same frontier node, so that node alone enforces the host's politeness and nothing needs coordinating across machines. Fetchers can live with their frontier partition. A node that dies loses only in-memory queues, which are rebuilt from the URL store.
AdvancedDeep dive: inside the frontier
New URLs + priority front: by priority F1 high F2 F3 low Selector weighted pick route by host back: one per host news.example shop.example blog.example wiki.example … thousands Min-heap hosts by next allowed time Fetchers
Front queues decide what matters; back queues and the heap decide what is allowed. This split is the classic Mercator design.

This two-level layout comes from Mercator, the crawler Heydon and Najork described in 1999 (the textbook Introduction to Information Retrieval by Manning, Raghavan and Schütze walks through it), and it's still the standard answer:

  • Front queues hold URLs by priority (PageRank-like importance, how often the page changes, how close it is to a seed). The selector takes from them with weights (say F1 70%, F2 20%, F3 10%), so low-priority work still makes progress.
  • Back queues hold URLs by host, one host per queue. When a back queue runs empty, the selector refills it from the front queues; when a host has no queue yet, it gets one.
  • The heap holds one entry per back queue, keyed by the host's next allowed fetch time. A fetcher pops the top; if its time is in the future, it waits; otherwise it takes the head URL of that host's queue and pushes the host back with now + delay. That's exactly the model's loop, with a heap instead of a sort.
  • The delay adapts. A fixed 1 s is the starting point; the textbook refinement is a gap an order of magnitude larger than the host's most recent fetch time (a page that took 2 s to serve means waiting about 20 s), plus backing off sharply on 429 or 503. A big site that serves pages in 50 ms can be given a shorter delay, if its robots.txt doesn't say otherwise.
  • Memory. Billions of queued URLs don't fit in RAM, so each queue keeps its head in memory and spills the rest to disk (or to the URL store), loading in batches.
AdvancedDeep dive: URL and content dedup

URLs must be normalised before the "seen?" check, or the same page enters the frontier many times: lowercase the scheme and host, drop the default port and the #fragment, resolve ./ and ../, sort query parameters, and remove known tracking parameters (utm_*, session ids). The page's <link rel="canonical"> tells you which variant the site prefers.

Then two separate dedup checks:

URL seen?Content seen?
QuestionHave we queued this URL before?Have we stored this text before?
WhenEvery extracted link (11,574/s)Every fetched page (386/s)
StructureBloom filter (12 GB for 10B URLs at 1%) backed by the URL storeExact: hash of the normalised body; near: SimHash
Cost of an errorA false positive skips a new URLA missed duplicate stores a copy

Exact content hashes catch mirrors and trivially different URLs. Most duplicates aren't byte-identical, though: the same article with a different date in the footer or a different ad. SimHash maps a document to a 64-bit fingerprint such that similar documents differ in only a few bits. Google researchers showed it works for crawling (Manku, Jain and Das Sarma, "Detecting Near-Duplicates for Web Crawling", WWW 2007): for 8 billion pages, 64-bit fingerprints with a threshold of 3 differing bits was a good choice.

A Bloom filter can't forget, and it fills up. Two practical answers: rebuild it from the URL store periodically (the store is the source of truth), or use one filter per crawl cycle. And because it lives in memory, shard it by URL hash across a few machines and checkpoint it to disk.

AdvancedDeep dive: robots.txt and politeness

RFC 9309 (2022) turned robots.txt from a 1994 convention into a standard. The rules a crawler must implement:

  • Fetch /robots.txt per host (scheme + host + port) before crawling it, and match your user-agent's group of Allow / Disallow rules; the most specific (longest) matching rule wins.
  • Cache it, but not for more than 24 hours unless the file is unreachable; standard HTTP caching headers may shorten that.
  • Parse at least the first 500 KiB; content past your limit can be ignored.
  • If robots.txt returns a 4xx (including 404), you may crawl everything. If it returns a 5xx or the server is unreachable, you must assume complete disallow; after a long outage (the RFC gives 30 days as an example) you may treat it as unavailable or keep using the cached copy.
  • Crawl-delay is not in the RFC; many crawlers honour it anyway, as an upper bound on rate.

Politeness beyond robots.txt: identify yourself with a user-agent that links to a page explaining the crawler and how to block it, honour 429 and Retry-After, and cap concurrent connections per IP address as well as per host, because thousands of small sites often share one server.

AdvancedDeep dive: traps and robustness

Some sites generate an infinite number of URLs: a calendar with a "next month" link forever, faceted search with every combination of filters, session ids in every link, or a misconfigured server that answers /a/b/a/b/a/b/… with the same page. A crawler without defences spends its whole budget on one such site. Defences, each cheap:

  • Per-host budgets: at most N pages per host per crawl cycle, set by the host's importance.
  • URL limits: maximum length (a few hundred to 2,000 characters) and maximum path depth; reject paths with repeating segments.
  • Content-based detection: many URLs on one host producing near-identical content (SimHash again) → lower that host's priority.
  • Fetch limits: connect and read timeouts of a few seconds, a maximum body size, at most a handful of redirects, and only the content types you process.
  • Isolation: parsing hostile HTML is untrusted-input processing; run parsers in a sandboxed process so a pathological page can only crash its worker.
AdvancedDeep dive: freshness and recrawling

The web changes unevenly: a news home page changes every few minutes, an old blog post never. Recrawling everything every month wastes most fetches; recrawling nothing makes the index stale. The standard approach is to estimate each page's change rate from history (did the content hash change between the last fetches?) and schedule next_due accordingly: halve the interval when a page has changed, double it (up to a cap) when it hasn't, weighted by importance. Conditional requests (If-Modified-Since, If-None-Match) let unchanged pages answer 304 Not Modified with no body, which saves bandwidth on both sides. Sitemaps (sitemap.xml with lastmod) are a hint from the site about what changed.

AdvancedWeb crawler trade-offs

Trade-offs and what to say

  • Bloom filter vs exact store for "seen?": 12 GB and 1% missed URLs, versus 80 GB of fingerprints (or a database lookup per link). Name the error and why it's acceptable here.
  • Politeness vs freshness: you will sometimes know a page changed and still wait for its host's slot. Politeness wins; a crawler that gets blocked gets nothing.
  • Partitioning by host keeps politeness local but makes very large hosts hot partitions; split the biggest hosts by path prefix with their share of the rate.
  • BFS vs priority: pure breadth-first is simple and fair but spends equal effort on spam and on important pages; priority queues fix that at the cost of a scoring system.
  • JavaScript rendering multiplies the cost per page many times over (a headless browser instead of an HTTP GET); render only for the hosts and pages where it changes the result.
Why can't the crawler just add more fetchers to go faster?

The crawl is behind schedule. Someone doubles the number of fetcher machines, and the pages-per-second rate barely moves. What's the likely cause, and what actually helps?

Show answer

The crawl is limited by politeness, not by fetchers: there aren't enough distinct hosts ready at any moment, so the extra fetchers sit waiting on the heap.

What’s happening
  1. Each host can be fetched at most about once a second. If the frontier's ready set is dominated by a few big hosts (a common result of following links from a few large seeds), the heap's top entries are all in the future, and every fetcher waits for the same few hosts.
  2. The model shows the ceiling plainly: 4 hosts, 1 page a second each, 374 s for 1,471 pages. Adding fetchers to that crawl would change nothing; adding hosts would.
  3. What helps is widening the frontier: more seeds across many hosts, priority that rewards host diversity, and back-queue refills that pull URLs from many hosts instead of draining one.
  4. For the few large hosts that can take more (their robots.txt and response times say so), lower the per-host delay or allow a few parallel connections, within what they tolerate.
  5. Then check the other stage limits: DNS resolution (uncached lookups), slow servers holding connections (timeouts), and parsing CPU. The question to ask first in any "more workers didn't help" problem is what are the workers waiting for?

#Design a notification system

Added

A notification system lets every other service say "tell this user that X happened" without knowing how. It decides which channels to use (push to iOS and Android, email, SMS, an in-app inbox), respects the user's preferences and quiet hours, renders the message from a template in their language, hands it to the right provider (APNs, FCM, an email service, an SMS gateway), retries when the provider fails, and records what happened. The interview question is about reliability and isolation: a user must get their login code within seconds even while a 30-million-user marketing campaign is going out, and must never get the same "your order shipped" twice.

The mental model is a post office sorting room. Letters arrive from many senders. Each one is checked against the recipient's instructions ("no junk mail", "nothing after 10 pm", "send parcels to my office"), copied into the right bag per carrier, and handed to the carrier. Carriers sometimes refuse a bag ("come back later"), so it goes back on the shelf with a note to retry, and after enough refusals it goes to a dead-letter office for a human to look at. A clerk stamps each letter's number in a ledger so a letter that arrives twice is only delivered once. And registered letters (login codes) have their own counter, so they never wait behind a sack of flyers.

Those ideas (preferences, fan-out to channels, retries with backoff, a dead-letter queue, dedup) are the core of the design, and this model runs them all on a fake clock:

notify.mjs
// Notification pipeline on a fake clock: preferences -> fan-out per channel -> workers with retries
// (exponential backoff, full jitter, seeded PRNG) -> dead-letter queue; dedup by eventId:user:channel.
function mulberry32(a) {
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(3);
let now = 0;
const timers = []; // a tiny scheduler: [{ at, fn }]
const later = (ms, fn) => timers.push({ at: now + ms, fn });
const log = (msg) => console.log(`t=${(now / 1000).toFixed(2).padStart(5)}s ${msg}`);

const prefs = {
  ana: { push: true, email: true, sms: false, marketing: true },
  raj: { push: false, email: true, sms: true, marketing: false },
};
const CHANNELS_FOR = { shipped: ["push", "email"], login_code: ["sms"], promo: ["push", "email"] };
// Fake providers: how many times each (channel, user) fails before it succeeds.
const failuresLeft = { "push:ana": 1, "email:ana": 99 };
const accepted = new Set(); // event ids already taken in (dedup at the door)
const sent = new Set(); // eventId:user:channel already delivered (dedup at the worker)
const dlq = [];
const MAX_ATTEMPTS = 4, BASE_MS = 1000;

function accept(event) { // the notification service's entry point
  if (accepted.has(event.id)) return log(`${event.id} already accepted, duplicate event dropped`);
  accepted.add(event.id);
  const p = prefs[event.user];
  if (event.type === "promo" && !p.marketing) return log(`${event.id} ${event.type} -> ${event.user}: opted out of marketing, dropped`);
  const channels = CHANNELS_FOR[event.type].filter((c) => p[c] || event.type === "login_code");
  log(`${event.id} ${event.type} -> ${event.user}: fan out to ${channels.join(", ")}`);
  for (const channel of channels) deliver({ ...event, channel, attempt: 1 });
}

function deliver(job) {
  const key = `${job.id}:${job.user}:${job.channel}`;
  if (sent.has(key)) return log(`${key} already sent, duplicate skipped`);
  const left = failuresLeft[`${job.channel}:${job.user}`] ?? 0;
  if (left === 0) { sent.add(key); return log(`${key} delivered (attempt ${job.attempt})`); }
  // (a worker that crashes after this line but before acking makes the queue redeliver the job)
  failuresLeft[`${job.channel}:${job.user}`] = left - 1;
  if (job.attempt === MAX_ATTEMPTS) { dlq.push(key); return log(`${key} failed ${MAX_ATTEMPTS} times -> dead-letter queue`); }
  const wait = Math.round(rand() * BASE_MS * 2 ** job.attempt); // full jitter: random in [0, base * 2^attempt)
  log(`${key} provider 503 (attempt ${job.attempt}), retry in ${wait} ms`);
  later(wait, () => deliver({ ...job, attempt: job.attempt + 1 }));
}

accept({ id: "e1", type: "shipped", user: "ana" });
accept({ id: "e1", type: "shipped", user: "ana" }); // upstream redelivered the same event
accept({ id: "e2", type: "login_code", user: "raj" });
accept({ id: "e3", type: "promo", user: "raj" });
later(2000, () => { log("queue redelivers e2:raj:sms (its worker died before acking)"); deliver({ id: "e2", user: "raj", channel: "sms", attempt: 1 }); });
while (timers.length) {
  timers.sort((a, b) => a.at - b.at);
  const t = timers.shift();
  now = t.at;
  t.fn();
}
console.log(`dead letters: ${dlq.join(", ") || "none"}`);
Result
t= 0.00s e1 shipped -> ana: fan out to push, email
t= 0.00s e1:ana:push provider 503 (attempt 1), retry in 1440 ms
t= 0.00s e1:ana:email provider 503 (attempt 1), retry in 77 ms
t= 0.00s e1 already accepted, duplicate event dropped
t= 0.00s e2 login_code -> raj: fan out to sms
t= 0.00s e2:raj:sms delivered (attempt 1)
t= 0.00s e3 promo -> raj: opted out of marketing, dropped
t= 0.08s e1:ana:email provider 503 (attempt 2), retry in 1825 ms
t= 1.44s e1:ana:push delivered (attempt 2)
t= 1.90s e1:ana:email provider 503 (attempt 3), retry in 599 ms
t= 2.00s queue redelivers e2:raj:sms (its worker died before acking)
t= 2.00s e2:raj:sms already sent, duplicate skipped
t= 2.50s e1:ana:email failed 4 times -> dead-letter queue
dead letters: e1:ana:email
What’s happening
  1. Event e1 (order shipped, for Ana) arrives. accept records its id, reads Ana's preferences (push and email on) and fans out one job per channel: e1:ana:push and e1:ana:email. From here on the channels are independent: a failing email provider can't hold up the push.
  2. Both providers return a 503 on the first attempt. Each job schedules a retry with full jitter: a random wait in [0, 1000 × 2^attempt) ms, so 1,440 ms for the push and 77 ms for the email. Random waits stop thousands of failed jobs from retrying in lockstep and hammering a recovering provider (see Retries, dead-letter queues and poison messages). The PRNG is seeded, so the waits are the same every run.
  3. The same event e1 arrives again: the upstream service retried because it didn't see our acknowledgement. accept has already seen the id and drops it at the door. Without that check Ana would get two of each.
  4. e2 (a login code for Raj) goes straight to SMS and is delivered; e3 (a promotion) is dropped by preferences because Raj opted out of marketing. Security messages such as login codes bypass marketing opt-outs (the login_code rule in the filter): users can turn off promotions, not their own sign-in codes.
  5. At t = 2 s the queue redelivers e2:raj:sms because, in this scenario, the worker that sent it died before acknowledging. The worker-level check (sent holds eventId:user:channel) recognises it and skips the duplicate SMS. That's the second dedup layer: the door catches duplicate events, the worker catches duplicate jobs from an at-least-once queue.
  6. The push succeeds on attempt 2 at t = 1.44 s. The email fails four times (MAX_ATTEMPTS) and goes to the dead-letter queue at t = 2.50 s, where it can be inspected and replayed instead of being retried forever or silently lost.
AdvancedNotification system requirements

Requirements

Functional:

  • Services send a notification by type (order_shipped, login_code, weekly_digest, promo) to one user, a list, or a segment ("all users in Canada who bought in the last 30 days").
  • Channels: push (iOS via APNs, Android via FCM), email, SMS, and an in-app inbox. Each type has default channels.
  • User preferences per category and channel, plus quiet hours in the user's time zone; legal unsubscribe for marketing email.
  • Templates with localisation; scheduled sends ("Tuesday at 10:00 in each user's time zone").
  • Status per notification and channel: queued, sent, delivered, failed, opened.

Non-functional:

  • Transactional messages are fast: a login code reaches the provider within a second or two, whatever else is going on.
  • At-least-once delivery with dedup: never silently lose a transactional message; duplicates are rare and caught.
  • Isolation: a campaign or a failing provider must not delay other traffic.
  • Don't spam: per-user caps (no more than N promotional pushes a day) and digests for high-frequency events.
  • Scale: 50 million daily active users.
AdvancedNotification system estimates in code

Estimates

Assumptions: 50M daily active users; per user per day, 4 pushes, 0.5 emails, 0.05 SMS (mostly login codes); peak 5× average; 70% of push goes to Android devices through FCM; a marketing campaign to 30M Android devices; 1 KB per notification record kept 90 days. Two provider limits from their docs (October 2026): FCM's default quota of 600,000 messages per minute per project, and 100 SMS message parts a second for a US short code (AWS End User Messaging; US toll-free numbers get 3).

notify-estimates.mjs
// Assumptions (stated in the text): 50M daily active users; per user per day 4 push, 0.5 email, 0.05 SMS;
// peak = 5x average; a marketing campaign to 30M Android devices; FCM's default quota of 600,000 messages a minute
// per project (Firebase docs, 2026); US short code 100 SMS parts/s (AWS End User Messaging docs, 2026);
// 1 KB per notification record kept 90 days; 70% of push goes to Android devices through FCM.
const dau = 50e6, DAY = 86_400, peak = 5;
const perUser = { push: 4, email: 0.5, sms: 0.05 };
const fcmPerMinute = 600_000, shortCodeMps = 100, campaign = 30e6, recordBytes = 1_000, keepDays = 90;
const fmt = (n) => Math.round(n).toLocaleString("en-US");

let total = 0;
for (const [channel, n] of Object.entries(perUser)) {
  const perDay = dau * n;
  total += perDay;
  console.log(`${channel.padEnd(5)} ${fmt(perDay).padStart(11)}/day  ${fmt(perDay / DAY).padStart(6)}/s avg  ${fmt((perDay / DAY) * peak).padStart(6)}/s peak`);
}
console.log(`all   ${fmt(total).padStart(11)}/day, history ${((total * recordBytes * keepDays) / 1e12).toFixed(1)} TB for ${keepDays} days`);
const fcmPeakPerMin = ((dau * perUser.push) / DAY) * peak * 0.7 * 60;
console.log(`FCM at peak: ${fmt(fcmPeakPerMin)}/min = ${((fcmPeakPerMin / fcmPerMinute) * 100).toFixed(0)}% of the default quota`);
// Campaign: how long at the default FCM quota?
console.log(`campaign to ${campaign / 1e6}M Android devices at ${fmt(fcmPerMinute)}/min: ${(campaign / fcmPerMinute).toFixed(0)} minutes`);
// SMS at peak versus one short code.
const smsPeak = ((dau * perUser.sms) / DAY) * peak;
console.log(`SMS peak ${smsPeak.toFixed(0)}/s vs ${shortCodeMps}/s per short code: ${Math.ceil(smsPeak / shortCodeMps)} short code(s), more if messages are multi-part`);
Result
push  200,000,000/day   2,315/s avg  11,574/s peak
email  25,000,000/day     289/s avg   1,447/s peak
sms     2,500,000/day      29/s avg     145/s peak
all   227,500,000/day, history 20.5 TB for 90 days
FCM at peak: 486,111/min = 81% of the default quota
campaign to 30M Android devices at 600,000/min: 50 minutes
SMS peak 145/s vs 100/s per short code: 2 short code(s), more if messages are multi-part
What’s happening
  1. Push dominates: 200M a day, 2,315 a second on average and 11,574 at peak. Email is 25M a day, SMS 2.5M. The pipeline as a whole handles about 13,000 messages a second at peak, which is a normal Kafka workload; the internal throughput is not the hard part.
  2. The provider quota is. At peak, the Android share alone is 486,111 FCM messages a minute, 81% of the default quota. One busy evening plus a campaign would exceed it, and FCM answers excess traffic with 429 QUOTA_EXCEEDED until the next quota minute. So the design needs a send-rate limiter per provider in front of FCM, and a quota increase request (Firebase's docs describe one: usage regularly at 80% or more of quota, a low error rate).
  3. A campaign to 30M Android devices at 600,000 a minute takes 50 minutes even with the whole quota. So campaigns are always throttled sends spread over time, never a burst, and they must run in a lower-priority lane so login codes keep flowing during those 50 minutes.
  4. SMS at peak is 145 a second against 100 per short code: two short codes, or more if messages are long (an SMS longer than one part counts as several parts). SMS is also the expensive channel, which is another reason it's used for codes, not news.
  5. History is 227.5M records a day: 20.5 TB for 90 days. That's a wide-column or time-partitioned store where old partitions are dropped, not deleted row by row.
AdvancedNotification system API sketch

API

API sketch
POST /v1/notifications
  Idempotency-Key: order-5521-shipped
  { "type": "order_shipped", "userIds": ["u_ana"], "templateId": "shipped_v3",
    "data": { "orderId": 5521, "eta": "2026-10-27" }, "priority": "high", "sendAt": null }
  -> 202 Accepted  { "notificationId": "n_8d1e", "status": "queued" }

POST /v1/campaigns   { "segmentId": "seg_ca_buyers_30d", "templateId": "fall_promo", "sendAt": "2026-10-28T10:00",
                       "timeZone": "recipient", "ratePerMinute": 300000 }  -> 202 { "campaignId": "cmp_44" }

GET  /v1/notifications/n_8d1e        -> per-channel status: push delivered, email failed (dead-lettered)
GET  /v1/users/u_ana/preferences     PUT  /v1/users/u_ana/preferences
POST /v1/devices   { "platform": "android", "token": "fcm-token…", "appVersion": "5.2.0" }

The API answers 202 Accepted, not 200: accepting the request means "durably queued", and the actual sending happens asynchronously. The idempotency key is the producer's own event id (order-5521-shipped), which is exactly what the door check in the model uses.

AdvancedNotification system data model

Data model

  • notifications: id, idempotency_key (unique), type, user_id, template_id, data, priority, created_at.
  • deliveries: notification_id, channel, status, attempts, provider_message_id, last_error, updated_at. One row per channel, so "push delivered, email failed" is representable.
  • preferences: user_id, category (transactional, social, marketing), channel, enabled, plus quiet_start, quiet_end, time_zone.
  • devices: user_id, platform, token, last_seen, active. A user has several devices; tokens go stale and must be pruned.
  • templates: id, version, locale, channel, subject, body.
  • sent keys (Redis): eventId:user:channel with a TTL of a few days, for the worker-level dedup.

Preferences and devices are read on every notification, so they're cached (a few hundred bytes per user, 50M users, a few tens of GB).

AdvancedNotification system architecture

High-level design

Order service Auth service Campaigns Notification service idempotency check preferences quiet hours templates per-user caps fan-out Prefs, devices, templates push queue high · low lanes email queue high · low lanes SMS queue codes first Push workers Email workers SMS workers APNs, FCM Email provider SMS gateway Retry with backoff then dead-letter queue Delivery log status, opens
Each channel has its own queue, workers and provider, so a slow email provider or a campaign on the low-priority lane can't delay a login code.
  1. Accept. A producer calls the API (or publishes an event the notification service consumes). The service checks the idempotency key; if new, it stores the notification and acknowledges with 202. From this moment the message is durable.
  2. Decide. It loads the user's preferences and devices (from cache), applies category rules (transactional ignores marketing opt-outs; marketing respects quiet hours and per-user caps), picks the channels, and renders each channel's template in the user's locale.
  3. Fan out. One job per channel (and per device for push) goes onto that channel's queue, in the high or low lane by priority. Segment sends (campaigns) are expanded in batches by a separate campaign worker, at the campaign's configured rate, onto the low lane.
  4. Send. Channel workers take jobs, check the sent-key for dedup, call the provider through a per-provider rate limiter, and record the outcome. Transient failures (429, 5xx, timeouts) are retried with backoff; permanent ones (invalid token, unsubscribed address) are recorded and not retried.
  5. Track. Provider callbacks (email delivery and bounce webhooks, SMS delivery receipts) and client events (push opened) update the delivery log.
AdvancedDeep dive: delivery guarantees and dedup

You can't get exactly-once delivery to a phone: the provider call can succeed and the acknowledgement can be lost. What you build is at-least-once with deduplication, as described in Idempotency and exactly-once:

  • Durable before acknowledged. The 202 is sent only after the notification is stored (or the event committed to Kafka). A crash after that point delays the message; it can't lose it.
  • Door dedup: a unique constraint on idempotency_key turns a producer's retry into a no-op, as with e1 in the model.
  • Worker dedup: the sent-key eventId:user:channel is set after the provider accepts the message. A queue redelivery (worker died before acking, as with e2) finds the key and skips.
  • The remaining window. A worker that crashes after the provider accepted the message but before writing the sent-key will send it again on redelivery. That window is milliseconds, so duplicates are rare, not impossible. Where a duplicate really hurts, pass a provider-side id where the provider supports one (for example, APNs shows notifications that share an apns-collapse-id as a single notification), or make the message itself idempotent ("Your code is 482913", not "Here's another code").
  • Retries and the DLQ. Transient failures retry with exponential backoff and full jitter, capped at a few attempts or a time limit (a login code that arrives after 10 minutes is useless, so its retries stop early). Exhausted jobs go to a dead-letter queue with the error, for alerting and replay. A message that fails because of its own content (a template error) is a poison message: retrying won't help, so it goes to the DLQ at once.
AdvancedDeep dive: priorities and isolation

The estimates showed why isolation is the core problem: one campaign is 50 minutes of FCM's full default quota. If campaigns and login codes shared one queue, every code sent during those 50 minutes would wait behind millions of promotions. So:

  • Separate queues per channel, so a slow email provider backs up only email.
  • Separate lanes per priority within a channel, with workers reserved for the high lane (or a weighted consumer that always drains high first).
  • Rate limiting per provider, shared by all workers of that channel (the Design a rate limiter design, pointed outwards): FCM at, say, 90% of quota, leaving headroom so a burst of 429s can't happen. The campaign's own ratePerMinute sits under that.
  • Separate provider accounts or sender numbers for transactional and marketing traffic where providers allow it, so marketing complaints or quota exhaustion can't affect codes. SMS short codes are a common example: one for codes, one for marketing.
AdvancedDeep dive: providers and devices
ChannelProvider facts that shape the design (checked October 2026)Design consequence
Android pushFCM HTTP v1: 600,000 messages/minute per project by default; excess gets 429 QUOTA_EXCEEDED until the next quota minuteA per-project send limiter; campaign pacing; ask for a quota increase early
iOS pushAPNs provider API over long-lived HTTP/2 connections; it rejects bad tokens, but developers report it doesn't reliably flag every uninstalled appKeep connections open in the push workers; prune tokens APNs rejects and tokens the app hasn't refreshed in weeks
SMS (US)Short code 100 parts/s, toll-free 3 parts/s, 10DLC throughput set by the registered campaign (AWS End User Messaging)Codes on a short code; spreading traffic over many toll-free numbers is "snowshoeing" and gets filtered
EmailProviders expose bounce and complaint webhooks; reputation suffers from sending to dead addressesSuppress hard-bounced addresses; honour unsubscribe headers for marketing

Device tokens deserve their own maintenance loop: a token stops working when the app is uninstalled or the user reinstalls, and the provider usually tells you so on a later send (FCM answers UNREGISTERED; APNs answers 410 Unregistered, though not always promptly, as above). Mark it inactive immediately, or the system keeps paying for sends that can't arrive and the provider may throttle you for it. Apps should re-register their token on every launch so last_seen stays current.

AdvancedDeep dive: not spamming people
  • Per-user caps: a counter per user per category per day (the rate limiter again, keyed by user): "at most 3 marketing pushes a day".
  • Quiet hours: marketing and social notifications for a user in quiet hours are delayed until the window ends (a delayed job, see Design a distributed job scheduler); transactional ones go now.
  • Collapsing and digests: 40 likes in 10 minutes should be one notification ("Priya and 39 others liked your photo"). Hold social events per user for a short window and send a summary; on the device, push collapse keys replace older notifications with newer ones.
  • Time zones: "10:00 local time" for a global campaign means 24+ separate send waves; the campaign worker computes each user's send time and schedules it.
AdvancedNotification system trade-offs

Trade-offs and what to say

  • Push vs pull: push notifications are best-effort (the provider and the OS can delay or drop them); the in-app inbox is the reliable record, fetched when the app opens.
  • At-least-once vs at-most-once: for a login code a duplicate is harmless and a loss is not; for "you've been charged" a duplicate causes alarm, so dedup matters more than speed.
  • One service vs per-channel services: one notification service with per-channel workers keeps preferences and dedup in one place; at very large scale, channels become separate services sharing the preference store.
  • What breaks first: provider quotas and reputation, not your own throughput.
The campaign went out and login codes were 20 minutes late

Marketing sent a campaign to 30M users. During it, SMS login codes and push security alerts arrived up to 20 minutes late. The notification service's CPU was fine. What happened, and what's the fix?

Show answer

The campaign and the urgent messages shared a queue or a provider budget, so urgent messages waited behind the campaign: head-of-line blocking, not a capacity problem in the service.

What’s happening
  1. The campaign produced 30M push jobs in minutes. If they went onto the same push queue as security alerts, every alert enqueued after them waits until the workers drain everything ahead of it.
  2. Workers can't drain faster than FCM allows: at 600,000 a minute, 30M jobs is 50 minutes of backlog, so an alert arriving early in the campaign waits tens of minutes even with idle CPUs.
  3. SMS codes were late too, which suggests a shared resource beyond the push queue: a single worker pool for all channels, or a shared database of deliveries, or the campaign expanding segments on the same workers that accept transactional requests.
  4. The fix is isolation: per-channel queues, a high-priority lane with reserved workers, the campaign paced by its own rate (below the provider quota, leaving headroom), and campaign expansion done by separate workers.
  5. Then verify it with the numbers: the high lane's queue depth and age should stay near zero during the next campaign; alert on "oldest message in the high lane older than 30 seconds".

#Design a distributed job scheduler

Added

A job scheduler runs work at a time: "send this reminder in 24 hours", "rebuild the search index every night at 02:00", "retry this failed webhook in 5 minutes", "charge every subscription on its renewal date". On one machine that's cron plus a loop. Across a fleet it becomes a distributed-systems problem with three hard parts: finding the jobs that are due without scanning everything, making sure each due run is executed by exactly one worker at a time, and noticing when that worker has died, without being fooled when it was only paused.

The mental model is a restaurant's ticket rail. Tickets are clipped to the rail with the time they're due. When one is due, a cook takes it and writes their name on it with a deadline ("mine until 7:42"); while cooking they keep pushing the deadline out. If a cook walks off, the deadline passes and the ticket goes back on the rail for someone else. The twist that makes it safe: every time a ticket is handed out it gets a new serial number, and the pass only accepts a dish carrying the current number. So if the first cook comes back from a long break and plates the dish, it's refused instead of being served twice. The deadline is a lease, pushing it out is a heartbeat, and the serial number is a fencing token.

Here's all of that in one run: a timing wheel holding delayed jobs, two workers claiming them with leases, and one worker freezing in the middle of a job.

scheduler.mjs
// Delayed jobs in a hashed timing wheel (60 slots x 1 s), run by workers that hold leases.
// A lease is "this job is mine until time T"; a worker extends it with heartbeats. If the worker stops,
// the lease runs out and another worker takes the job. Each claim gets a higher fencing token, so a
// zombie worker that wakes up late can't overwrite the newer run's result. Fake clock, 1 s ticks.
const SLOTS = 60, LEASE = 10, HEARTBEAT = 3;
const wheel = Array.from({ length: SLOTS }, () => []);
const jobs = new Map(); // the jobs table: id -> { state, leaseUntil, token, owner, attempts }
const ready = [];
let now = 0;
const log = (msg) => console.log(`t=${String(now).padStart(3)}s ${msg}`);

function schedule(id, delay, work) {
  jobs.set(id, { id, work, state: "scheduled", token: 0, attempts: 0 });
  wheel[(now + delay) % SLOTS].push({ id, rounds: Math.floor(delay / SLOTS) }); // rounds: laps to wait
}
function tickWheel() {
  const slot = wheel[now % SLOTS];
  wheel[now % SLOTS] = slot.filter((e) => e.rounds-- > 0); // not this lap: keep it, one lap fewer
  for (const e of slot) if (e.rounds < 0) { jobs.get(e.id).state = "ready"; ready.push(e.id); log(`${e.id} is due`); }
}
function complete(worker, job, token) { // UPDATE jobs SET state='done' WHERE id=? AND token=?
  if (token !== job.token) return log(`${worker.name} finishes ${job.id} with token ${token}: REJECTED (current token ${job.token})`);
  job.state = "done";
  log(`${worker.name} finishes ${job.id} with token ${token}: done after ${job.attempts} attempt(s)`);
}

const workers = [
  { name: "W1", busy: null, pausedFrom: 9, pausedUntil: 35 }, // a 26 s stop-the-world pause (or a network partition)
  { name: "W2", busy: null },
];
schedule("report", 5, 25);   // due in 5 s, takes 25 s of work
schedule("digest", 7, 3);    // due in 7 s, takes 3 s
schedule("cleanup", 70, 2);  // due in 70 s: one lap around the 60-slot wheel, then 10 more slots

for (now = 0; now <= 80; now++) {
  tickWheel();
  for (const job of jobs.values()) // the reaper: expired leases go back to ready
    if (job.state === "running" && job.leaseUntil <= now) { job.state = "ready"; ready.push(job.id); log(`${job.id} lease expired (owner ${job.owner} silent)`); }
  for (const w of workers) {
    if (now >= w.pausedFrom && now < w.pausedUntil) continue; // paused: no heartbeats, no progress
    if (w.busy) {
      const job = jobs.get(w.busy.id);
      if (now >= w.busy.doneAt) { complete(w, job, w.busy.token); w.busy = null; }
      else if ((now - w.busy.started) % HEARTBEAT === 0 && job.token === w.busy.token) job.leaseUntil = now + LEASE;
    }
    if (!w.busy && ready.length) { // claim: UPDATE ... SET owner=?, token=token+1, leaseUntil=? WHERE state='ready'
      const job = jobs.get(ready.shift());
      Object.assign(job, { state: "running", owner: w.name, token: job.token + 1, leaseUntil: now + LEASE });
      job.attempts++;
      w.busy = { id: job.id, token: job.token, started: now, doneAt: now + job.work };
      log(`${w.name} claims ${job.id} (token ${job.token}, lease until t=${job.leaseUntil}s)`);
    }
  }
}
Result
t=  5s report is due
t=  5s W1 claims report (token 1, lease until t=15s)
t=  7s digest is due
t=  7s W2 claims digest (token 1, lease until t=17s)
t= 10s W2 finishes digest with token 1: done after 1 attempt(s)
t= 18s report lease expired (owner W1 silent)
t= 18s W2 claims report (token 2, lease until t=28s)
t= 35s W1 finishes report with token 1: REJECTED (current token 2)
t= 43s W2 finishes report with token 2: done after 2 attempt(s)
t= 70s cleanup is due
t= 70s W1 claims cleanup (token 1, lease until t=80s)
t= 72s W1 finishes cleanup with token 1: done after 1 attempt(s)
What’s happening
  1. schedule drops each job into a timing wheel: 60 slots, one per second, like a clock face. A job due in 5 s goes in slot 5; one due in 70 s goes in slot 10 with rounds = 1, meaning "not this lap, the next". Each tick looks at one slot only, so the cost per second doesn't depend on how many jobs are scheduled further out.
  2. At t = 5 s the wheel releases report, and W1 claims it: state becomes running, owner W1, token 1, lease until t = 15 s. At t = 7 s W2 claims digest and finishes it at t = 10 s with token 1: accepted.
  3. W1 heartbeats at t = 8 s (its lease moves to 18 s) and then freezes from t = 9 s to t = 35 s: a 26-second stop-the-world pause, or a network partition. From outside, a paused worker and a dead worker look identical: they both go silent.
  4. At t = 18 s the reaper sees the lease has run out and puts report back to ready; W2 claims it with token 2. The job runs again from the start, which is why job handlers must be idempotent (or resumable).
  5. At t = 35 s W1 wakes up, believes it has finished, and tries to complete with token 1. The completion is a conditional update, WHERE id = ? AND token = ?, and the current token is 2, so it's REJECTED. Without the token, W1's late result would overwrite or duplicate W2's. W2 finishes at t = 43 s with token 2: done after 2 attempts.
  6. cleanup, due in 70 s, sat in slot 10 through one full lap (its rounds went 1 → 0 at t = 10 s) and fired at t = 70 s. That's how a small wheel holds long delays; hierarchical wheels (seconds, minutes, hours) do the same with less waiting around.
AdvancedJob scheduler requirements

Requirements

Functional:

  • Schedule a one-off run at a time or after a delay, and recurring runs from a cron expression in a time zone.
  • A job has a target (a queue topic, an HTTP endpoint, or a registered handler), a payload, a timeout, a retry policy and a maximum number of attempts.
  • Pause, resume, cancel, run now; see each run's state, attempts, timings and errors.
  • A policy for overlaps (a run is due while the previous one is still running) and for missed runs (the scheduler was down at 02:00).

Non-functional:

  • No lost runs: a due run is executed at least once, even if workers or schedulers crash.
  • No concurrent duplicates: at most one worker executes a given run at any moment, and late results from an expired lease are rejected.
  • On time: p99 lateness of a few seconds, including at the top of every hour.
  • Horizontally scalable and multi-tenant (one tenant's 100,000 jobs at 00:00 must not delay everyone else).
  • Scale: 10 million runs a day.
AdvancedJob scheduler estimates in code

Estimates

Assumptions: 10M runs a day, half of them from hourly cron schedules; 60% of hourly schedules are written as "minute 0" (people like round numbers; this share is an assumption, measure yours); jobs run 30 s on average with a heartbeat every 10 s; 1 KB per run record kept 30 days; one database primary can claim about 5,000 jobs a second when each claim query takes one run (a rule of thumb for a row-locking claim query on good hardware).

scheduler-estimates.mjs
// Assumptions (stated in the text): 10M job runs a day, half of them from hourly cron schedules;
// 60% of hourly schedules are written as "minute 0"; jobs run 30 s on average with a heartbeat every 10 s;
// 1 KB per run record kept 30 days; one database primary claims ~5,000 jobs/s, one run per claim query (rule of thumb).
const runsPerDay = 10e6, hourlyShare = 0.5, onTheHour = 0.6;
const avgRunSec = 30, heartbeatSec = 10, recordBytes = 1_000, keepDays = 30, claimsPerSec = 5_000;
const fmt = (n) => Math.round(n).toLocaleString("en-US");

console.log(`average: ${fmt(runsPerDay / 86_400)} runs/s`);
const hourlyRunsPerHour = (runsPerDay * hourlyShare) / 24;
const burst = hourlyRunsPerHour * onTheHour;
console.log(`due at hh:00:00 exactly: ${fmt(burst)} runs`);
console.log(`draining that burst at ${fmt(claimsPerSec)} claims/s: ${(burst / claimsPerSec).toFixed(0)} s late for the last job`);
// Writes per run: create/schedule, claim, heartbeats, finish.
const writesPerRun = 1 + 1 + Math.floor(avgRunSec / heartbeatSec) + 1;
console.log(`writes per run: ${writesPerRun} -> ${fmt((runsPerDay * writesPerRun) / 86_400)}/s average`);
// Concurrency: Little's law, runs in flight = arrival rate x duration.
console.log(`runs in flight on average: ${fmt((runsPerDay / 86_400) * avgRunSec)}; just after hh:00: up to ${fmt(Math.min(burst, claimsPerSec * avgRunSec))}`);
console.log(`run history: ${((runsPerDay * recordBytes * keepDays) / 1e9).toFixed(0)} GB for ${keepDays} days`);
Result
average: 116 runs/s
due at hh:00:00 exactly: 125,000 runs
draining that burst at 5,000 claims/s: 25 s late for the last job
writes per run: 6 -> 694/s average
runs in flight on average: 3,472; just after hh:00: up to 125,000
run history: 300 GB for 30 days
What’s happening
  1. The average is tiny: 116 runs a second. A design sized for the average would be one database and a few workers.
  2. The top of the hour is not average: 5M hourly runs a day is 208,333 an hour, and 60% of those are due at the same instant, 125,000 runs at hh:00:00. This is the number that drives the design.
  3. Claiming 125,000 runs at 5,000 a second takes 25 seconds, so the last job of the burst starts 25 s late, outside the "few seconds" target. The first fix is free and already in the worker protocol: batch claims. claim(workerId, max) takes up to max runs in one statement (the claim query's LIMIT), so a worker asking for 20 pays one statement, one commit and one round trip for 20 runs. Each row still costs its lock and its update, so the gain is less than 20×; measure it. Only when batched claims still can't drain the burst in time, partition claims across several databases (or shards), and spread schedules (the deep dive on cron).
  4. Each run costs about 6 writes (create, claim, 3 heartbeats, finish): 694 writes a second on average, many times that during the burst. Heartbeat writes are the biggest share; longer heartbeat intervals cut them but slow down crash detection.
  5. Little's law (runs in flight = arrival rate × duration) gives 3,472 runs in flight on average; after hh:00, up to 125,000 at once if the workers exist. Either the worker pool scales for the burst, or the burst queues and finishes later. That's a product question to ask: does the 00 cron need to start at :00:00, or by :05?
  6. History is 300 GB for 30 days: time-partitioned, with old partitions dropped.
AdvancedJob scheduler API sketch

API

API sketch
POST /v1/jobs
  { "name": "nightly-reindex", "schedule": { "cron": "0 2 * * *", "timeZone": "America/Toronto" },
    "target": { "type": "queue", "topic": "reindex" }, "payload": { "index": "products" },
    "timeoutSec": 1800, "retry": { "maxAttempts": 3, "backoffSec": 60 },
    "overlap": "skip", "missed": "run-once" }                       -> 201 { "jobId": "j_77" }
POST /v1/jobs   { "name": "reminder", "runAt": "2026-10-25T09:00:00Z", "payload": { … },
                  "idempotencyKey": "reminder-order-5521" }          -> 201 { "jobId": "j_78" }
POST /v1/jobs/j_77:pause     POST /v1/jobs/j_77:run-now     DELETE /v1/jobs/j_78
GET  /v1/jobs/j_77/runs?limit=20   -> [{ "runId": "r_9", "scheduledFor": "…", "state": "done", "attempts": 2, … }]

Worker protocol (internal):
claim(workerId, max)                 -> [{ runId, payload, token, leaseUntil }]
heartbeat(runId, token)              -> { leaseUntil } | error "lease lost"
complete(runId, token, result)       -> ok | error "stale token"

overlap mirrors the choice Kubernetes CronJobs expose as concurrencyPolicy (Allow, Forbid, Replace), and missed mirrors their startingDeadlineSeconds: a run that can't start within the deadline is skipped rather than started late.

AdvancedJob scheduler data model

Data model

Two tables: job definitions, and one row per run. The run table is the queue. This is the schema used for the claim query in the deep dive below, run on PostgreSQL 18:

jobs-setup.sql
CREATE TABLE jobs (
  id          bigint PRIMARY KEY,
  name        text NOT NULL,
  run_at      timestamptz NOT NULL,
  state       text NOT NULL DEFAULT 'scheduled',  -- scheduled | running | done | dead
  owner       text,
  token       bigint NOT NULL DEFAULT 0,            -- fencing token, +1 on every claim
  lease_until timestamptz,
  attempts    int NOT NULL DEFAULT 0
);
CREATE INDEX jobs_due ON jobs (run_at) WHERE state IN ('scheduled', 'running');
INSERT INTO jobs (id, name, run_at)
SELECT g, 'job-' || g, now() - (10 - g) * interval '1 second' FROM generate_series(1, 6) g;

In the full design the definitions live in their own table (job_id, cron, time_zone, next_run_at, target, payload, retry, overlap, missed, paused), and runs carry job_id and scheduled_for with a unique constraint on (job_id, scheduled_for): when two dispatchers both try to create the 02:00 run of the same job, the second insert fails instead of creating a duplicate run. The partial index jobs_due covers only unfinished runs, so it stays small however much history the table holds.

AdvancedJob scheduler architecture

High-level design

Clients services, UI Scheduler API create, pause, runs Jobs + runs DB partitioned by job id lease · token Dispatchers one per partition cron → next run timing wheel Due runs ready to claim Lease reaper expired → ready Worker pool claim with a lease heartbeat every 10 s finish with token Downstream rejects stale tokens heartbeat, finish call + token
The database is the source of truth for every run; dispatchers only decide when runs become due, and leases decide who holds them.
  1. Define. The API validates the job, computes its first next_run_at from the cron expression and time zone, and stores the definition.
  2. Materialise runs. Each dispatcher owns a set of partitions (assigned by leader election or by leases in a coordination store such as etcd or ZooKeeper; see Leader election and consensus). It creates run rows a little ahead of time (say the next 5 minutes of runs) and advances each job's next_run_at. The unique (job_id, scheduled_for) constraint makes this safe even if two dispatchers briefly think they own the same partition.
  3. Release. Runs due soon sit in the dispatcher's in-memory timing wheel; when their slot comes up they're marked ready (or pushed onto a queue for the workers). Runs further out stay only in the database, so a dispatcher restart loses nothing: it reloads the next few minutes.
  4. Claim and run. Workers claim ready runs with a lease and a new fencing token, heartbeat while working, and finish with a conditional update carrying the token.
  5. Recover. The reaper (or the claim query itself, as below) returns runs with expired leases to ready, counting the attempt; after maxAttempts the run is marked dead and alerts fire.
AdvancedDeep dive: claiming work without double-claiming

The claim must be atomic: two workers asking at the same moment must get different runs. Three common ways to do it:

MechanismHow a claim worksGood atLimits
Database rows + SKIP LOCKEDUPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED)Transactions with your data, exact state, simple opsClaim rate of one primary; batch claims first, then partition for more
A queue (SQS, RabbitMQ)Receive hides the message for a visibility timeout; delete when doneHuge throughput, managedSQS: visibility timeout at most 12 hours, delay queues at most 15 minutes (AWS docs); state lives elsewhere
Redis sorted setZADD due runAt runId, claim with a Lua script doing ZRANGEBYSCORE + ZREMVery fast, timing built inDurability depends on Redis persistence; leases are hand-built

The database version is the one to know in detail. Here is the claim query:

claim.sql
-- Claim up to 2 due jobs: not yet started, or started by a worker whose lease ran out.
UPDATE jobs
SET state = 'running', owner = :'worker', token = token + 1,
    lease_until = now() + interval '30 seconds', attempts = attempts + 1
WHERE id IN (
  SELECT id FROM jobs
  WHERE run_at <= now()
    AND (state = 'scheduled' OR (state = 'running' AND lease_until < now()))
  ORDER BY run_at
  LIMIT 2
  FOR UPDATE SKIP LOCKED
)
RETURNING id, name, owner, token;

It was run on PostgreSQL 18.6 (in Docker) against the six-job table above, by two workers at almost the same moment: worker A holds its transaction open for 2 seconds, and worker B runs the same query 0.7 s after A. Then the whole test runs again with claim-noskip.sql, the same query with plain FOR UPDATE:

claim-demo.sh
# Two workers claim at almost the same time. A holds its transaction open for 2 s; B starts 0.7 s later.
psql() { docker exec -i sd10-pg psql -U postgres -q -X "$@"; }
reset() { psql -c "DROP TABLE IF EXISTS jobs" && psql < jobs-setup.sql; }
claim() { # claim <worker> <file> [hold seconds]
  { echo "\\set worker $1"; [ -n "$3" ] && echo "BEGIN;"; echo "\\timing on"; cat "$2"; echo "\\timing off"
    [ -n "$3" ] && { echo "\\o /dev/null"; echo "SELECT pg_sleep($3);"; echo "\\o"; echo "COMMIT;"; }; } | psql
}
for variant in claim.sql claim-noskip.sql; do
  reset; echo "== $variant"
  claim A $variant 2 > a.txt & sleep 0.7; claim B $variant > b.txt; wait
  echo "-- worker A"; cat a.txt; echo "-- worker B"; cat b.txt
done
Result
== claim.sql
-- worker A
 id | name  | owner | token 
----+-------+-------+-------
  1 | job-1 | A     |     1
  2 | job-2 | A     |     1
(2 rows)

Time: 0.679 ms
-- worker B
 id | name  | owner | token 
----+-------+-------+-------
  4 | job-4 | B     |     1
  3 | job-3 | B     |     1
(2 rows)

Time: 3.399 ms
== claim-noskip.sql
-- worker A
 id | name  | owner | token 
----+-------+-------+-------
  1 | job-1 | A     |     1
  2 | job-2 | A     |     1
(2 rows)

Time: 0.761 ms
-- worker B
 id | name  | owner | token 
----+-------+-------+-------
  4 | job-4 | B     |     1
  3 | job-3 | B     |     1
(2 rows)

Time: 1274.662 ms (00:01.275)
What’s happening
  1. The inner SELECT finds due runs that are either not started or whose lease has expired, so the same query is both the claim and the reaper. ORDER BY run_at LIMIT 2 takes the two oldest.
  2. FOR UPDATE locks the rows it returns; SKIP LOCKED tells PostgreSQL to skip rows another transaction has locked instead of waiting for them. PostgreSQL's docs describe exactly this use: avoiding lock contention when several consumers read a queue-like table.
  3. Worker A locked jobs 1 and 2 and kept its transaction open for 2 seconds. Worker B ran the same query meanwhile: rows 1 and 2 were locked, so it skipped them and claimed 3 and 4 in 3.4 ms (the order of RETURNING rows isn't guaranteed, hence 4 before 3).
  4. Without SKIP LOCKED, worker B ends up with the same rows but takes 1,275 ms: it blocked on row 1 until A committed, re-checked rows 1 and 2, found they no longer matched (state was now running with a live lease), and moved on to 3 and 4. Correct, but every worker queues behind every other, so claims become serial and the claim rate collapses exactly when many workers are busy.
  5. In production, the claim transaction commits immediately; the lease, not the row lock, is what protects the run while it executes. Holding a row lock for the length of a 30-second job would pin a database connection per running job and break if the worker's connection dropped.
AdvancedDeep dive: timing wheel, heap or polling
ApproachInsertFind what's dueFits
Poll the database every secondINSERTIndex range scan on run_at <= now()Simple, durable; cost per poll grows with partitions and polling frequency
Min-heap in memoryO(log n)Peek the top: O(1); pop O(log n)Exact times, moderate counts
Timing wheelO(1): drop into a slotLook at one slot per tickHuge numbers of timers with coarse (1 s) precision
Hierarchical timing wheelsO(1)One slot per tick, plus occasional cascades from the coarser wheelLong delays without a huge wheel

Kafka uses hierarchical timing wheels for its own delayed operations ("purgatory"): Confluent's post on the redesign explains that inserts and deletes happen for every request, so they needed to be O(1), which a heap isn't. For a job scheduler the usual combination is the database as the durable store, polled every few seconds for the next window of runs, and a wheel or heap in memory for the exact second. The database answers "what's due in the next 5 minutes?" cheaply with the partial index; memory answers "what's due this second?".

AdvancedDeep dive: leases, heartbeats and fencing tokens

The model's timeline is the whole argument, and each parameter is a trade-off:

  • Lease length. Short leases detect dead workers fast (the run restarts sooner) but a long GC pause or a slow network turns a healthy worker into a "dead" one and causes a duplicate run. Long leases avoid that but leave crashed runs stalled. A common setting is a lease of 3× the heartbeat interval: one or two heartbeats can be lost without losing the lease.
  • Heartbeats are writes: 3 per 30-second run in the estimates, the biggest share of the 694 writes a second. Batch heartbeats per worker (one write renewing all its leases) to cut them.
  • Fencing tokens. A lease alone can't stop a paused worker from acting after it has lost the lease, because it doesn't know time has passed. The token makes the receiver do the check: the database's WHERE token = ? in the model, or a downstream system that remembers the highest token it has seen and refuses lower ones. This is the same fencing idea used for leader leases in Leader election and consensus.
  • Idempotent jobs. Leases give "at most one worker at a time", not "exactly once": a run that crashed half-way will run again. Make handlers idempotent (an idempotency key per run id, an upsert instead of an insert, "charge for period 2026-10" instead of "charge now"), as in Idempotency and exactly-once.
AdvancedDeep dive: cron at scale
  • The top-of-the-hour spike is the scheduler's defining load (125,000 runs at hh:00:00 in the estimates). Spread it: when a user writes 0 * * * * and doesn't need the exact minute, add a stable per-job offset (hash(jobId) % 300 seconds) so runs spread over five minutes. AWS EventBridge Scheduler, for example, offers this as a "flexible time window". Keep exact times for jobs that need them, claim in batches, and partition claims so the exact ones are spread over shards.
  • Missed runs. If the scheduler was down from 01:55 to 02:10, does the 02:00 run happen? The choices: run once on recovery, run every missed occurrence, or skip if later than a deadline. Kubernetes CronJobs, for example, skip a run that can't start within startingDeadlineSeconds, and stop trying to catch up after more than 100 missed schedules. State the policy per job.
  • Overlaps. If the 02:00 run is still going at 03:00: allow both, skip the new one, or cancel the old one. Default to skip for jobs that touch shared state.
  • Time zones and daylight saving. "02:30 every day in America/Toronto" doesn't exist on the spring-forward day (clocks jump from 02:00 to 03:00), and on the fall-back day it's 01:30 that happens twice (02:00 goes back to 01:00), so a 01:30 job would be due twice. In Europe the change happens at a different hour, so which times vanish or repeat depends on the zone. Compute the next run in the job's time zone with a real time-zone library, and decide what those days do (most schedulers run once).
AdvancedJob scheduler trade-offs

Trade-offs and what to say

  • Database vs queue: a database gives you state, queries, uniqueness and transactions with your business data; a queue gives you throughput and less code. Many designs use both: the database decides what's due, a queue distributes the work.
  • Push vs pull: dispatchers can push due runs to workers (lower latency, needs worker tracking) or workers can pull (simpler, self-balancing). Pull with leases is the default.
  • Precision vs cost: one-second precision is cheap; sub-second precision for millions of timers needs memory-resident wheels and careful clocks.
  • At-least-once is the honest guarantee; exactly-once effects come from idempotent jobs and fencing at the receiver.
The nightly job ran twice

The 02:00 billing job charged some customers twice last night. Logs show worker W1 started the run at 02:00:01, then nothing from W1 until 02:00:47, when it logged "run complete". Worker W2 started the same run at 02:00:31. Leases are 30 s with heartbeats every 10 s. What happened, and what would have prevented the double charge?

Show answer

W1 paused (a GC pause, a VM migration or a network stall) for longer than its lease, so the lease expired and W2 legitimately took the run; when W1 resumed, nothing stopped it from finishing its charges.

What’s happening
  1. W1 claimed at 02:00:01 with a 30 s lease, valid until 02:00:31. Its heartbeats should have extended the lease, but W1 was silent: whatever happened stopped both its heartbeats and its progress.
  2. At 02:00:31 the lease expired and W2 claimed the run with a new token. From the scheduler's point of view this is correct: it can't tell a paused worker from a dead one.
  3. W1 resumed and continued charging customers, and its "run complete" at 02:00:47 shows it never checked whether it still held the lease. The customers it charged after 02:00:31 were also charged by W2.
  4. Prevention, in order of strength: make each charge idempotent (key billing:customer:period, so the payment provider or ledger refuses a second charge); pass the fencing token to the billing service and have it reject tokens lower than the highest it has seen; and have the worker check its lease before each side effect.
  5. The scheduler did its job (no lost run); the damage came from a non-idempotent job without fencing. That's the sentence to say: leases guarantee progress, fencing and idempotency guarantee safety.

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.