System Design NotesRohit’s interview study guide
Chapter 15

Review Questions

Rapid revision before an interview: short questions with model answers you can say out loud in a minute, each linked to the topic that explains it in depth.

5 topics

#Fundamentals and estimation questions

Added

This page is for the last few days before an interview, when you no longer need to learn a topic but need to say it well, quickly. Every question here is one an interviewer might ask, or one you should be able to answer for yourself in the middle of a design. The first paragraph of each answer is the version you'd say out loud: 30 to 90 seconds, with the decision first and the reason straight after. The walkthrough under it is the reasoning you need when the follow-up is "why?", and the link at the end goes to the topic that teaches it properly.

Think of it as a deck of flashcards with the working shown. Read the question, close your eyes, answer it in your own words, then open the answer and compare: did you name the decision, the number that drives it, and the trade-off? If one of the three is missing, that's the card to go back to. The estimate questions have small runnable models; their output was produced on Node 26 and is re-checked every time the site is validated, so you can trust the arithmetic and concentrate on the reasoning.

This first group covers the interview itself, estimation, latency and availability numbers, and the scaling fundamentals from the first two chapters.

How do you spend a 45-minute system design round?
Show answer

About 5 minutes of introductions, 35 of design, 5 for my questions, and inside the 35 a fixed order: requirements (5), estimates (2), API (3), data model (2), high-level design (10), deep dives (10), wrap-up (3). "I aim to have a complete, working high-level design on the board by about minute 27, so the second half can go on the two hardest parts. I write the requirements and numbers down where we can both see them, and I check in with you at each phase boundary."

What’s happening
  1. The order matters more than the exact minutes: requirements decide the numbers, the numbers decide the components, the API and data model decide what the boxes have to do. Skipping a step means guessing later.
  2. Requirements get 5 minutes, not 15. Ask only the questions whose answers change the design (scale, read/write ratio, latency, consistency, durability), write the answers down and move on.
  3. The high-level design must cover every functional requirement end to end before you go deep. A beautiful deep dive on a design that can't do one of the core features scores worse than a complete simple design.
  4. Deep dives are where senior candidates earn their level: pick the two hardest parts (or the ones the interviewer steers you to), compare at least two alternatives and say why one wins at these numbers.
  5. The wrap-up names bottlenecks, failure modes and what you'd do next. It shows you know the design's limits, which is a signal in itself.

Learn more: A repeatable framework and What the interviewer is looking for.

The interviewer says "design a photo-sharing app". What do you ask first?
Show answer

The questions whose answers change the design. "Which features are in scope: upload, feed, follow, likes, comments? How many daily users, and how often does each upload and view? So what's the read/write ratio? Which paths must be fast, and how fast? How bad is downtime, and is it worse for viewing than uploading? Can a user see a slightly stale feed? Can we ever lose a photo?" Then I write down three to five core features, the numbers, and what's out of scope.

What’s happening
  1. Features decide the API and the data model; agreeing that comments and search are out of scope saves ten minutes of design you'd never be scored on.
  2. Users and actions per user feed the estimate. "50 M daily users, 0.1 uploads and 100 views each" turns into QPS, storage and bandwidth in two minutes.
  3. Read/write ratio decides the main problem: 1,000 views per upload means caching and a CDN matter far more than write scaling.
  4. Latency, availability, consistency and durability are the non-functional requirements, and each needs a number or a guarantee: "feed p99 under 300 ms", "viewing 99.99%, uploads 99.9%", "a feed may be 30 s stale", "no photo is ever lost".
  5. Questions that don't change the design (the colour of the button, which cloud) waste time. If you can't say what you'd do differently for each answer, don't ask it.

Learn more: Functional and non-functional requirements.

Why is "it should be scalable and highly available" not a requirement?
Show answer

Because no design can fail it, so it can't be checked. A requirement needs a number and a scope: "50 M daily users at a 3× evening peak; viewing 99.99% available and uploads 99.9%; feed p99 under 300 ms; a deleted photo disappears everywhere within a minute." Now every box I draw can be tested against it, and the interviewer can see that I know which parts deserve the expensive guarantees.

What’s happening
  1. "Scalable" becomes a load: daily users, actions per user, peak factor, growth over the next years.
  2. "Highly available" becomes a target per path, because the cost of each nine is high: 99.99% allows about 4.3 minutes of downtime a month, 99.9% about 43 minutes.
  3. "Fast" becomes a percentile and a path: p99 (not the average) on the feed read, measured at the edge.
  4. "Consistent" becomes a staleness bound or a guarantee such as read-your-writes, and "durable" becomes "never lose an acknowledged upload".
  5. Saying the adjectives scores nothing because interviewers hear them in every round; replacing them with numbers is one of the cheapest ways to look senior.

Learn more: Functional and non-functional requirements and Common mistakes.

200 M daily users, 50 reads and 2 writes each per day. What's the load?

State the peak factor you assume, and check the "100,000 seconds a day" shortcut.

Show answer

About 116 K reads a second on average and 347 K at a 3× peak, and about 14 K writes a second at peak: 25 reads per write. "So reads dominate: stateless API servers behind a load balancer, a cache in front of the database for the hot reads, and the write path is a few primary databases' worth, not a write-scaling problem."

rq-qps.mjs
// A social app: 200M daily users, 50 reads and 2 writes each per day, peak = 3x average.
const dau = 200e6, readsPerUser = 50, writesPerUser = 2, peakFactor = 3;
const DAY = 86_400;
const k = (n) => `${(n / 1000).toFixed(1)}K`;

const readQps = (dau * readsPerUser) / DAY;
const writeQps = (dau * writesPerUser) / DAY;
console.log(`reads:  ${k(readQps)}/s average, ${k(readQps * peakFactor)}/s peak`);
console.log(`writes: ${k(writeQps)}/s average, ${k(writeQps * peakFactor)}/s peak`);
console.log(`read:write = ${readsPerUser / writesPerUser}:1`);
// The whiteboard shortcut: a day is "about 100,000 seconds".
const shortcut = (dau * readsPerUser) / 1e5;
console.log(`shortcut reads: ${k(shortcut)}/s (${((shortcut / readQps - 1) * 100).toFixed(1)}% vs exact)`);
Result
reads:  115.7K/s average, 347.2K/s peak
writes: 4.6K/s average, 13.9K/s peak
read:write = 25:1
shortcut reads: 100.0K/s (-13.6% vs exact)
What’s happening
  1. Requests per day are users × actions: 200 M × 50 = 10 billion reads and 200 M × 2 = 400 M writes.
  2. Divide by the 86,400 seconds in a day: 115.7 K reads and 4.6 K writes per second on average.
  3. Multiply by the peak factor, here 3 (a rule of thumb for consumer apps; ask or state it): 347 K reads and 13.9 K writes per second. Systems are sized for the peak, not the average.
  4. The shortcut "a day is about 100,000 seconds" gives 100 K reads a second, 13.6% low. That's fine for an order of magnitude, but say you're rounding, and round in the direction that makes the system bigger if it matters.
  5. Read the result back as decisions: 347 K reads a second means a cache tier and many app servers; 14 K writes a second means one carefully chosen partition key, and probably sharding within a few years.

Learn more: Back-of-the-envelope estimation.

How much storage does a chat app need for five years?

200 M daily users each send 40 messages a day; a stored message is about 200 bytes with its ids and timestamps; three replicas.

Show answer

About 1.6 TB of new messages a day, 0.58 PB a year, and about 8.8 PB over five years with three replicas. "That rules out one database server: it's a partitioned store, keyed by conversation so each chat is read from one partition, with older history possibly moved to cheaper storage."

rq-storage.mjs
// Chat storage: 200M daily users each send 40 messages a day, 200 bytes per message
// (text plus ids and timestamps), 3 replicas, kept 5 years. Decimal units.
const dau = 200e6, msgsPerUser = 40, bytesPerMsg = 200, replicas = 3, years = 5;
const perDay = dau * msgsPerUser * bytesPerMsg; // bytes of new messages per day, one copy
const TB = 1e12, PB = 1e15;
console.log(`messages per day: ${(dau * msgsPerUser / 1e9).toFixed(0)} billion`);
console.log(`one copy: ${(perDay / TB).toFixed(1)} TB/day, ${(perDay * 365 / PB).toFixed(2)} PB/year`);
console.log(`${years} years x ${replicas} replicas: ${(perDay * 365 * years * replicas / PB).toFixed(1)} PB`);
console.log(`write rate: ${Math.round(dau * msgsPerUser / 86_400).toLocaleString("en-US")} messages/s average`);
Result
messages per day: 8 billion
one copy: 1.6 TB/day, 0.58 PB/year
5 years x 3 replicas: 8.8 PB
write rate: 92,593 messages/s average
What’s happening
  1. Messages per day: 200 M × 40 = 8 billion, which is about 92,600 writes a second on average before any peak factor. That number alone says "partitioned writes".
  2. Bytes per day for one copy: 8 billion × 200 B = 1.6 TB. Times 365 is 0.58 PB a year.
  3. Five years and three replicas multiply: 0.58 × 5 × 3 ≈ 8.8 PB. Forgetting copies (replicas, backups, indexes) is one of the classic estimation slips.
  4. Media is not in this number. Photos and voice notes would dwarf text and belong in object storage with only their keys in the message row.
  5. Decimal units (1 TB = 10^12 bytes) are deliberate: estimates use powers of ten, and the binary/decimal gap (about 10% at TB) doesn't change any decision.

Learn more: Back-of-the-envelope estimation and Partitioning and sharding strategies.

Your estimate says 17.5 GB/s of image egress. Is a 100 Gbit/s link enough?
Show answer

No. 17.5 GB/s is 140 Gbit/s, because network links are sold in bits and storage in bytes. "So even the average doesn't fit one 100 Gbit/s link, and the 3× peak is about 420 Gbit/s. This traffic has to come from a CDN, with object storage as its origin."

What’s happening
  1. Multiply bytes by 8 to get bits: 17.5 GB/s × 8 = 140 Gbit/s.
  2. Peak is the average times the peak factor: 140 × 3 = 420 Gbit/s.
  3. Mixing the two units understates network needs by a factor of 8, which is the difference between "one link" and "a CDN contract".
  4. The other slips on the same list: per day vs per second (forgetting to divide by 86,400), DAU vs MAU, forgetting copies, and sizing for the average instead of the peak.
  5. This is chapter 1's photo app: 57.9 K image views a second at 300 KB each. The number justifies the CDN box on its own.

Learn more: Back-of-the-envelope estimation and CDNs.

Put these in order of latency: a DRAM read, an NVMe SSD read, a round trip in the same zone, a round trip to another zone, a round trip from US east to the UK.
Show answer

DRAM about 100 ns, NVMe 4 KB read about 60 µs, same-zone round trip about 300 µs, zone to zone about 1–1.5 ms, US east to the UK about 78 ms. "The surprise is the middle: on modern hardware a local SSD read is faster than asking another server in the same data centre. And crossing an ocean costs about 250 times more than a round trip inside the zone (78 ms against 300 µs), which is why a request's synchronous path stays inside one region."

What’s happening
  1. Memory is effectively free for request handling: about 100 ns for a cache miss to DRAM, a thousand times less than any network hop.
  2. NVMe reads are tens of microseconds (60 µs is the typical queue-depth-1 figure for a data-centre SSD). "Disk is slow" is a spinning-disk-era rule.
  3. Same zone is about 300 µs per round trip, so an in-process cache in front of Redis is worth it for the very hottest keys.
  4. Zone to zone is about 1–1.5 ms. Synchronous replication across zones is affordable for a database commit.
  5. Cross-region is tens to hundreds of milliseconds (78 ms US east to UK, 261 ms UK to Australia, Azure's 2026 medians), and physics sets the floor: light in fibre covers about 1 km every 4.9 µs. One such call can eat most of a latency budget.

Learn more: Latency numbers to know.

Why does a brand-new HTTPS connection from Virginia to London take about 234 ms before the first response?
Show answer

Three round trips at about 78 ms each: one for TCP's handshake, one for TLS 1.3's, and one for the request itself. "So I keep connections open and pooled between services, terminate TLS at an edge close to users, and never put a fresh cross-region connection on a request's critical path."

What’s happening
  1. TCP needs SYN, SYN-ACK before data can flow: one round trip, 78 ms.
  2. TLS 1.3 needs one more round trip to agree keys (TLS 1.2 needed two): another 78 ms.
  3. Only then does the HTTP request go out and the response come back: the third 78 ms, plus the server's own time.
  4. 3 × 78 = 234 ms, against a typical 100–300 ms budget for the whole request. A warm pooled connection pays only the last round trip. (This leaves out the DNS lookup, often cached; a cold one adds another round trip to the resolver.)
  5. CDNs and edge proxies help users for the same reason: the handshakes happen with a nearby edge (a few milliseconds away), and the edge keeps warm connections to the origin.

Learn more: Latency numbers to know and CDNs.

How much downtime do 99.9% and 99.99% allow, and what happens to availability when services call each other?

A load balancer (99.99%), three API instances (99.9% each, any one can serve), a database (99.95%), and optionally a payments API (99.9%) called synchronously.

Show answer

99.9% allows about 43 minutes a month and 99.99% about 4.3 minutes. Redundant copies multiply their failure chances (parallel), while dependencies on the request path multiply their availabilities (serial). "Three API instances are effectively never all down, so the path is set by the single load balancer and database: about 99.94%. Adding one synchronous 99.9% dependency drops it to 99.84%, more than an hour a month, so non-critical dependencies get a fallback or move off the request path."

rq-availability.mjs
// A request path: load balancer -> API -> database, plus a call to a payments API.
// Availabilities per month; a 30-day month has 43,200 minutes.
const MONTH_MIN = 30 * 24 * 60;
const serial = (...parts) => parts.reduce((a, b) => a * b, 1);
const parallel = (a, n) => 1 - (1 - a) ** n; // n independent copies, any one is enough
const show = (label, a) => {
  const down = (1 - a) * MONTH_MIN;
  const text = down < 1 ? `${(down * 60).toFixed(3)} s` : `${down.toFixed(1)} min`;
  console.log(`${label.padEnd(36)}${(a * 100).toFixed(7).padStart(12)}%  ${text.padStart(9)} down/month`);
};

const lb = 0.9999, api = 0.999, db = 0.9995, payments = 0.999;
show("one API instance", api);
show("API x3 behind the LB (parallel)", parallel(api, 3));
show("LB + API x3 + DB (serial)", serial(lb, parallel(api, 3), db));
show("+ payments called synchronously", serial(lb, parallel(api, 3), db, payments));
Result
one API instance                      99.9000000%   43.2 min down/month
API x3 behind the LB (parallel)       99.9999999%    0.003 s down/month
LB + API x3 + DB (serial)             99.9400049%   25.9 min down/month
+ payments called synchronously       99.8400649%   69.1 min down/month
What’s happening
  1. Downtime is (1 − availability) × period. With a 30-day month of 43,200 minutes, 99.9% gives 0.001 × 43,200 = 43.2 minutes; each extra nine divides it by ten.
  2. Parallel copies fail only when all fail at once: 1 − (1 − 0.999)³ = 1 − 10⁻⁹, a few milliseconds a month. Redundancy is how you get nines from cheaper parts (assuming failures are independent, which shared deploys and configs often break).
  3. Serial dependencies must all work: 0.9999 × 0.999999999 × 0.9995 ≈ 99.94%, 25.9 minutes a month. The single database dominates.
  4. One more synchronous 99.9% dependency multiplies in: 99.94% × 99.9% ≈ 99.84%, 69.1 minutes. Your availability can't beat your critical dependencies' unless you can do something useful without them: serve cached data, queue the work, degrade the feature.
  5. Set the target per path: viewing might deserve 99.99% (automatic failover, no single points of failure across zones), uploads 99.9%.

Learn more: Availability, SLAs, SLOs and SLIs and Graceful degradation and fallbacks.

What's the difference between an SLI, an SLO and an SLA, and what is an error budget for?
Show answer

The SLI is the measurement, the SLO is the target you set yourself, the SLA is the contract with consequences, and the error budget is the SLO's allowed failure, spent deliberately. "For the feed, the SLI is the fraction of requests that succeed within 300 ms, the SLO is 99.9% of them over 30 days, and any SLA we sign is looser than that. The 0.1% of allowed failures is the error budget: while there's budget left we ship quickly, and when a burn-rate alert says we're spending it fast, we slow releases and fix reliability."

What’s happening
  1. SLI: a precise definition, including what counts as good. "Non-error response within 300 ms, measured at the load balancer" is an SLI; "uptime" isn't.
  2. SLO: target plus window, "99.9% over 30 days". Internal SLOs are stricter than external SLAs, so you notice and fix problems before you owe anyone credits (S3 Standard is designed for 99.99% but its SLA promises 99.9%).
  3. SLA: the legal promise, usually service credits. Credits are a discount, not insurance; they rarely cover an outage's real cost.
  4. Error budget: 1 − SLO. At 99.9% that's 43 minutes of full outage a month, or a longer partial one. It turns "should we deploy on Friday?" into a number.
  5. Alerts page on burn rate, how fast the budget is being spent: 2% errors against a 99.95% SLO is a burn rate of 40, which would empty a 30-day budget in under a day.

Learn more: Availability, SLAs, SLOs and SLIs and Observability: logs, metrics and traces.

Vertical or horizontal scaling: which do you reach for first?
Show answer

Horizontal for the stateless tiers, from the start; vertical first for the database, for as long as it's enough. "App servers hold no state, so I put them behind a load balancer and add instances, spread over zones. The database is the hard part: I'd scale it up, move reads to replicas and caches, and only shard when the write rate or the data size forces it, because sharding costs cross-shard queries and transactions for ever."

What’s happening
  1. Vertical (a bigger machine) needs no code change and keeps transactions and joins simple, but has a ceiling, a price curve that gets steep near the top, and is still one machine.
  2. Horizontal (more machines) needs the tier to be stateless or partitioned, and gives you redundancy as a side effect: N + 1 instances across zones survive losing one.
  3. More app servers don't help a database bottleneck; they add connections and queries to it. Say which tier is the bottleneck before saying how you'll scale it.
  4. Spreading capacity over more, smaller units also reduces spare capacity: with N zones, surviving one loss needs only N / (N − 1) of peak, 1.5× with three zones rather than 2× with an active-standby pair.
  5. The usual order for a database: tune and index, scale up, read replicas, cache, then partition.

Learn more: Vertical vs horizontal scaling and Stateless services and where the state goes.

Your service is "stateless". Where did the state go?
Show answer

Into shared stores built to hold it: sessions in Redis behind an opaque cookie, files in object storage via presigned uploads, durable data in the database, counters and rate limits in Redis. "The test I apply is: what breaks if the next request goes to a different instance, or this instance disappears right now? If the answer is nothing, the load balancer can use any algorithm and autoscaling can add and remove instances freely."

What’s happening
  1. Sessions: an HTTP-only cookie carries an opaque id; the session lives in a replicated Redis. Services that only need identity get a short-lived signed token from the gateway.
  2. Uploads: the browser sends bytes straight to object storage with a presigned URL; the API never holds the file between requests.
  3. Hidden local state is the usual bug: in-memory rate limiters (N instances let a client do N× the limit), setInterval jobs that now run on every instance, caches that must be correct.
  4. Sticky sessions are a workaround, not a design: losing a server logs out its users, and with hash % N routing, losing one of three servers moved two-thirds of users in chapter 2's model.
  5. The only stickiness worth keeping is inherent: a WebSocket lives on one server, and even there messages go through pub/sub so any server can deliver them.

Learn more: Stateless services and where the state goes.

L4 or L7 load balancer?
Show answer

L7 at the edge for HTTP, L4 where I need raw connection throughput or non-HTTP protocols, and per-request balancing for long-lived HTTP/2 or gRPC connections. "An L7 balancer terminates TLS and sees the request, so it can route by host and path, retry idempotent requests, run canary weights and report per-route errors. An L4 balancer only sees connections, which is cheaper and protocol-agnostic, but it pins a whole HTTP/2 connection, and every request on it, to one backend."

What’s happening
  1. L4 works on TCP/UDP: it picks a backend per connection by IP and port. Millions of connections, low cost, client IPs preserved, no knowledge of HTTP.
  2. L7 parses HTTP: path and header routing, TLS termination, retries, header injection (request ids), rate limits, per-route metrics.
  3. The classic trap: gRPC services behind a Kubernetes Service (L4). Each caller opens one HTTP/2 connection and multiplexes everything over it, so one pod runs hot and new pods sit idle. Fix with an L7 proxy or client-side round_robin, or a maximum connection age.
  4. Both need health checks (active probes plus passive outlier detection), and the balancer itself must be redundant: managed fleets across zones, or two self-managed instances sharing a virtual IP.
  5. Common shape: anycast or DNS → L4 at the very front → L7 per region → services.

Learn more: Load balancers: L4 vs L7.

Round robin, least connections or power of two choices?
Show answer

Round robin when requests are uniform, least outstanding requests when they vary, and power of two choices when there are many balancers or the load data is stale. "Real APIs mix cheap and expensive calls, so round robin lets cheap requests queue behind expensive ones. Least outstanding requests fixes that, but many balancer nodes all seeing the same 'emptiest' server herd onto it. Picking two backends at random and sending to the less loaded one keeps most of the benefit and is robust to stale data, which is why Envoy's least-request balancer works that way by default."

What’s happening
  1. Round robin keeps no state and is fine for short, similar requests on identical instances. Weighted round robin handles mixed instance sizes and canaries.
  2. Least connections / outstanding requests adapts to request cost: in chapter 2's simulation at 80% load, p99 fell from about 670 ms to 185 ms for the same capacity.
  3. Herding: if 50 balancers each send to the server that looked emptiest a moment ago, that server gets flooded. Fresh servers suffer most, so use slow start to ramp a new backend's share over 30–60 s.
  4. Power of two choices picks two at random and takes the less loaded one. The busiest of 10 backends is chosen only when both picks land on it: 1% of the time instead of 10%.
  5. Hashing (IP hash, consistent hashing) is for affinity, such as routing a key to the cache node that holds it, not for spreading load.

Learn more: Load-balancing algorithms.

Three zones, each running at 70% CPU at peak. Are you redundant?
Show answer

No. Losing one zone puts 105% of the load on the other two, so they fail too. "For N active copies to survive one loss, each must run below (N − 1) / N of capacity at peak: 50% for two, 67% for three, 75% for four. I'd alert on that threshold rather than on 90%, and remember that autoscaling only helps after its warm-up time."

What’s happening
  1. Total work is 3 zones × 70% = 210% of one zone's capacity.
  2. After one zone fails, two remain: 210% / 2 = 105% each. Over 100% means queues grow without bound, timeouts start, retries add more load, and the second zone tips over.
  3. At 60% the survivors would run at 90%: alive, but with latency climbing steeply as utilisation nears 100%.
  4. The general rule: N copies, each below (N − 1) / N. More, smaller zones or cells need less idle headroom, which is the economic case for spreading out.
  5. The same arithmetic applies to active-active regions, database replicas taking reads, and cache nodes: redundancy only exists if the survivors can carry the load.

Learn more: Redundancy, failover and single points of failure and Autoscaling.

How fast can you fail a region over with DNS?
Show answer

Minutes, not seconds: health checks need about a minute and a half to agree, then resolvers keep the old answer for up to a TTL, plus a tail of clients that cache longer. "With a 60-second TTL that's two to three minutes for most users. If we need seconds, I'd put an anycast front door in front of the regions, so clients keep one stable IP and the edge switches regions itself. DNS is the coarse steering layer, not the per-request balancer."

What’s happening
  1. Detection: DNS health checks run from several locations and must agree before an endpoint is marked unhealthy, about 90 s with Route 53's defaults.
  2. Drain: after the record changes, each resolver keeps its cached answer for whatever is left of its TTL, uniformly up to 60 s for a 60 s TTL.
  3. The tail: some resolvers clamp low TTLs upwards, and long-lived clients keep open connections (and the old IP) regardless of TTL. Set maximum connection lifetimes in clients.
  4. Anycast (a global load balancer, Global Accelerator or a CDN) advertises one IP from many edges; the edge picks a healthy region in seconds, and clients never re-resolve.
  5. Inside a region an ordinary load balancer spreads requests across instances, so failover there is seconds.

Learn more: DNS, GeoDNS and anycast and Multi-region architecture.

Your CDN hit ratio goes from 95% to 99%. Why is that a big deal?
Show answer

Because the origin pays for misses, and the miss ratio fell from 5% to 1%: five times less origin traffic. "At 100,000 requests a second that's 5,000 a second reaching the origin versus 1,000. So normalising cache keys, adding an origin shield and using stale-while-revalidate are often worth more than adding origin servers. I'd track the origin request rate, not just the hit ratio."

What’s happening
  1. Origin load is traffic × (1 − hit ratio): 100,000 × 0.05 = 5,000 and 100,000 × 0.01 = 1,000.
  2. The same arithmetic in reverse is the scary version: a video CDN dropping from 95% to 90% doubles origin egress (10.4 to 20.8 Tbit/s in chapter 12's numbers).
  3. Levers: strip tracking parameters and sort the rest in the cache key, put an origin shield between edges and origin so each object is fetched once per region, use long TTLs on content-hashed assets, and serve stale while revalidating.
  4. What not to cache at a shared edge: personalised or authenticated responses (Cache-Control: private). Cache rules should be allow-lists of public paths, never "cache everything under /api/".
  5. A hit ratio dominated by a few hot files can look excellent while the long tail still hammers the origin, which is why the origin request rate is the number to watch.

Learn more: CDNs and Where to cache.

Autoscaling is on. Why did the database still fall over during the traffic spike?
Show answer

Because autoscaling the app tier multiplies connections and queries to a database that doesn't scale the same way. "Every new app instance opens its own pool, so 3× the instances can mean 3× the connections against one primary. I'd scale the stateless tier on its own saturation metric, cap the maximum to protect the database, put a connection pooler in front of it, and size the database's reads separately with replicas and caches. Autoscaling also takes minutes, so the first minutes of a spike need load shedding."

What’s happening
  1. Scaling on latency is the worst signal here: the database is slow, latency rises, autoscaling adds app servers, which add load to the database, which gets slower.
  2. Connections are a hard limit for many databases; a pooler (PgBouncer, RDS Proxy) multiplexes many app connections onto a few database ones.
  3. Pick the metric that measures this tier's saturation: CPU for CPU-bound code, in-flight requests per instance for I/O-bound Node services, backlog per worker for queue consumers.
  4. Warm-up matters: chapter 2's model saw a 300-second warm-up more than double the time spent overloaded compared with 60 seconds (13.5 minutes against 5.5).
  5. Keep a minimum that survives losing a zone, a scale-down stabilisation window so it doesn't flap, and scheduled scaling ahead of events you cause yourself.

Learn more: Autoscaling and Backpressure and load shedding.

How would you serve users in the US, Europe and Asia?
Show answer

Stateless tiers and static content in every region behind a global front door; data decided per dataset. "Read-heavy data replicates to every region and each user's writes go to their home region, which also covers data-residency rules. Contended data with invariants, like inventory, has one owning region per item, or a synchronous cross-region quorum if we must lose nothing, accepting 85 ms or more per write. Counters and likes can be active-active with CRDTs. And every region keeps the headroom, or a degraded mode, to take another region's users."

What’s happening
  1. Routing: latency-based DNS or anycast sends users to the nearest healthy region; a CDN serves assets everywhere.
  2. Single home per record avoids conflicts: a user's profile is written in their home region and read from local replicas elsewhere (seconds of staleness).
  3. Invariants ("never oversell", "unique username") need one owner or consensus at the moment of the write. Synchronous replication West Europe ↔ East US costs about 85 ms per commit, at most about 12 sequential commits a second per connection.
  4. Merge-friendly data (counters, sets of likes) can be written anywhere and merged; last-write-wins on a plain value silently drops one of two concurrent writes.
  5. "Active-active" without saying what happens when both regions write the same record is the most common multi-region mistake in interviews.

Learn more: Multi-region architecture and Conflict resolution: last-write-wins and CRDTs.

The interviewer asks "how would this handle 10× the traffic?" What's a strong answer?
Show answer

Re-run the estimate at 10×, walk the diagram with the new numbers, and fix the first component that breaks, rather than listing scaling techniques. "Stateless services and the CDN scale by adding capacity, so they're not design changes. The stateful parts are: at 10× the writes the database needs partitioning, the cache cluster grows, and hot keys get worse. I'd say what I'd measure first: per-server QPS from a load test, cache hit ratio and database write latency."

What’s happening
  1. Multiply the estimates you already have; don't invent new ones. Chapter 1's photo app goes from 17.4 K to 174 K feed QPS at peak and from about 419 Gbit/s to 4.2 Tbit/s of image egress.
  2. Walk each journey through the boxes with those numbers and ask where it saturates first.
  3. Treat stateless and stateful parts differently: "add servers" is a cost change for stateless tiers, but a design change (partition key, resharding plan, hot-key handling) for stateful ones.
  4. Mention second-order effects: more servers means more database connections, bigger fan-out, longer cold-cache recovery.
  5. This scores because it reuses your estimates, finds the first bottleneck instead of all of them, and shows you know where scaling is really hard.

Learn more: Drawing and narrating the design and Vertical vs horizontal scaling.

Halfway through, you notice a flaw in your own design. What do you do?
Show answer

Say it straight away: what the flaw is, which requirement it breaks, and how you'll fix it, then change the diagram. "For example: 'The like counter is a single row, and a viral post would send thousands of updates a second to it. I'll batch the increments through the event stream and write once a second, or shard the counter and sum on read.' Finding and fixing your own mistake is what a good design review looks like, and it's stronger evidence than a design that pretends to be perfect."

What’s happening
  1. Interviewers can only score what they hear; a silently patched diagram earns nothing, an explained fix earns a trade-off discussion.
  2. Tie the flaw to a requirement or a number: "at 5,000 likes a second this row is a lock hot spot" is concrete, "this might not scale" isn't.
  3. Offer the fix with its cost: batching makes the count a second stale; sharding makes reads sum N rows.
  4. The related mistakes to avoid: jumping to technology before requirements, no numbers, over-engineering (four microservices and Kafka for two endpoints), ignoring failure, and drawing in silence.
  5. Buzzwords are claims, not designs: every "eventually consistent" or "fault tolerant" invites "how, exactly?", so have the mechanism ready.

Learn more: Common mistakes and Drawing and narrating the design.

#Building blocks questions

Added

These questions cover the parts every design is assembled from: caches, databases and partitioning, object storage, queues and logs, APIs and real-time transports, rate limiters, IDs and the probabilistic structures. In an interview they rarely come as "define a Bloom filter"; they come as "why is that box there?" or "what happens when it fails?", so each answer leads with when you'd choose it and what it costs, not with a definition.

A useful mental model is a parts catalogue with a price tag on every part. Each block solves one problem (a cache saves reads, a queue absorbs bursts, a log lets many consumers replay, a Bloom filter saves lookups for things that don't exist) and charges for it in staleness, complexity, a new failure mode or memory. A good answer names both columns. If you can't say what a component costs, the interviewer will assume you'd add it anyway.

Walk me through cache-aside. Why delete the key on a write instead of updating it?
Show answer

Read: try the cache, on a miss read the database and set the key with a TTL. Write: update the database, then delete the key. "Deleting is safer than updating because two concurrent writers can update the cache in the opposite order to the database and leave the wrong value there for good; a delete just forces the next reader to load the current row. The TTL bounds any race I've missed, and losing a cache node costs performance, never data."

What’s happening
  1. On a read miss the app loads the row and sets product:42 with a TTL. The cache only ever holds copies, so it can be flushed or lose a node without losing anything.
  2. On a write, the app commits to the database first, then deletes the key. Deleting first is worse: a reader can refill the cache with the old row before the write commits, and it stays until the TTL.
  3. If two writers each set the cache after their commit, writer A can commit first but set last, leaving A's older value in the cache while the database holds B's. A delete has no such ordering problem.
  4. One small race remains (a slow reader puts back an old value just after the delete); the TTL bounds it, and version checks or leases close it if it matters.
  5. Choose by the cost of a stale value: cache-aside for most reads, write-behind for counters you can afford to lose a few seconds of, and never the cache for the balance check inside a money transfer.

Learn more: Caching strategies and Cache invalidation and consistency.

60,000 reads a second, and the database can take 8,000. What hit ratio do you need?
Show answer

At least 86.7%, and I'd design for well above it, because the database must also survive the cache losing a node or starting cold. "At 90% the database sees 6,000 reads a second, comfortable; at 80% it sees 12,000 and falls over. So the hit ratio isn't a nice-to-have metric here, it's a capacity dependency, and I'd add request coalescing, stale serving and load shedding so a dip doesn't become an outage."

rq-cache-hit.mjs
// 60,000 reads/s reach the cache tier; the database can take 8,000 reads/s.
const reads = 60_000, dbCapacity = 8_000;
for (const hit of [0.8, 0.9, 0.95, 0.99]) {
  const toDb = reads * (1 - hit);
  const verdict = toDb <= dbCapacity ? "ok" : "overloaded";
  console.log(`hit ${String(hit * 100).padStart(2)}%: ${toDb.toLocaleString("en-US").padStart(6)} reads/s to the DB (${verdict})`);
}
// The minimum hit ratio the database can survive:
console.log(`needs at least ${((1 - dbCapacity / reads) * 100).toFixed(1)}% hits`);
Result
hit 80%: 12,000 reads/s to the DB (overloaded)
hit 90%:  6,000 reads/s to the DB (ok)
hit 95%:  3,000 reads/s to the DB (ok)
hit 99%:    600 reads/s to the DB (ok)
needs at least 86.7% hits
What’s happening
  1. Reads reaching the database are reads × (1 − hit ratio): 12,000 at 80%, 6,000 at 90%, 600 at 99%.
  2. The minimum is 1 − 8,000 / 60,000 = 86.7%. Below that, every miss beyond capacity queues or fails.
  3. Each step up in hit ratio cuts the database load more than it sounds: 95% → 99% is five times fewer misses.
  4. The danger is the moment the hit ratio drops: a cache node failing (in chapter 3's estimate, losing one node of ten took the database from 10,000 to 29,000 reads a second), a deploy that changes key names, or a cold start. Size for that case or degrade gracefully.
  5. A cache the system can't run without is a critical dependency with no durability. Say what happens on a cold start and how long warming takes.

Learn more: Where to cache and Distributed caches.

A hot key expires and the database falls over. What happened, and how do you prevent it?
Show answer

A cache stampede: every request that missed at the same moment went to the database for the same value. "I layer the defences: coalesce misses inside each process so one process sends one query; across the fleet, let only one request recompute, with a short lock or, better, a soft TTL that serves the stale value while one refresher updates it; refresh the hottest keys from a background job so they never expire on a request; and jitter TTLs so keys written together don't expire together."

What’s happening
  1. Without protection, N concurrent misses become N identical database queries, and a heavy query times N is how one expiry takes the database down.
  2. In-process single-flight keeps a map of in-flight loads per process. It helps, but it leaves one query per server: 20 servers, 20 queries; after autoscaling to 200, 200.
  3. Fleet-wide: a lock in Redis (SET key:lock token NX PX 5000) so one caller loads, or a soft TTL so everyone keeps getting the old value while one refresh runs. Facebook's memcache leases cut their peak database load from 17 K to 1.3 K queries a second.
  4. Jittered TTLs stop a batch of keys loaded together from expiring together.
  5. A read-hot key (one viral item) is a different problem: one cache node carries all of it. Fix that with a small in-process cache with a one-second TTL, or by copying the key.

Learn more: Hot keys and cache stampedes.

LRU or LFU for your cache?
Show answer

LRU by default; LFU when a stable set of popular keys is being pushed out by one-off scans, and then LFU with decay. "LRU adapts instantly when what's popular changes, but one big scan of keys read once can flush the hot set. LFU protects long-term favourites, but without decay an old favourite keeps its place for ever and new hot keys can't build up a count. Redis offers both as allkeys-lru and allkeys-lfu, and LFU there decays."

What’s happening
  1. LRU evicts the least recently used key: cheap, good for recency-driven workloads such as feeds and sessions.
  2. LFU evicts the least frequently used key: good when popularity is stable, such as product pages or config.
  3. LFU's failure mode, from chapter 3's quiz: with 2 slots and the sequence A A A B C B C B C, LRU gets 6 hits and LFU 2, because A's old count keeps it in while B and C evict each other.
  4. TTL isn't an eviction policy, it's a freshness bound. Use both: a TTL on every cache key, and an eviction policy for when memory fills.
  5. The Redis trap: volatile-* policies only evict keys with a TTL, so a cache full of keys without TTLs starts rejecting writes like noeviction.

Learn more: Eviction policies: LRU, LFU and TTL.

You add an 11th node to a 10-node cache that uses hash(key) % N. What happens?
Show answer

About 91% of keys map to a different node, so the cache effectively empties and the database takes nearly the whole read load. "With consistent hashing only about 1/11 of the keys move, all of them to the new node, and with virtual nodes they come evenly from all ten old nodes. That's why caches and partitioned stores place keys on a ring or on fixed slots, never on % N."

What’s happening
  1. A key keeps its node under % N only if h % 10 === h % 11. That's true when h mod 110 is 0 to 9: 10 values out of 110, so 9.1% stay and 90.9% move, the 91% chapter 3's model measured.
  2. In general going from N to N + 1 nodes keeps only about 1 / (N + 1) of keys in place.
  3. On a consistent-hashing ring, nodes and keys hash onto the same circle and each key belongs to the next node clockwise. A new node takes only the arc before it: about 1 / (N + 1) of keys move, and nothing else changes.
  4. With one point per node, arcs have random lengths (the busiest node had 2.3× the average in the model) and the moved keys come from one neighbour. Virtual nodes (many points per node) even out both.
  5. Consistent hashing spreads keys, not traffic: a viral key still lives on one node. Redis Cluster reaches the same goal with 16,384 fixed slots.

Learn more: Consistent hashing and Resharding and hot partitions.

SQL or NoSQL for this design?
Show answer

I decide from the access patterns and the numbers, not the label. "If the data has relations and invariants that change together, like an order, its items and the stock, I want transactions, so a relational database, and at a few thousand writes a second one primary with replicas carries it. If the access is a key or a key-plus-range at very high write rates across regions, like 70,000 messages a second read by conversation, a wide-column or key-value store partitioned on that key fits. Search goes to a search index, files to object storage."

What’s happening
  1. List each read and write with its rate first: "place order: 14/s, needs a transaction", "messages by conversation, newest first: 70 K writes/s".
  2. Relational wins on transactions, joins, ad-hoc queries and secondary indexes, and scales further than people think with replicas and caching.
  3. Wide-column and key-value stores win on spreading huge write rates over many nodes and on multi-region replication, but every query must be designed into the key: no joins, and filtering outside the key is refused or a scan.
  4. "MongoDB because it scales" invites "fast at what?". A product name is not a reason; a sentence with the access pattern and a number is.
  5. Chapter 4's chat quiz is the reminder that size decides: 300,000 users sending 30 messages a day is about 310 writes a second at peak, and PostgreSQL is fine.

Learn more: Choosing a database in an interview and Modelling data for access patterns.

How do you choose a shard key?
Show answer

From the hottest access pattern: the key every frequent query carries, with enough distinct values to spread load, and no single value that's hot. "For chat it's the conversation id, hashed, because every read is 'messages in conversation X' and that keeps the hot path on one shard. Queries without the key become scatter-gather or need their own index table. And huge conversations get a time bucket in the key so no partition grows for ever."

What’s happening
  1. Locality: the queries that matter must hit one shard. Pick the key they all contain.
  2. Spread: enough distinct values and no dominant one. A tenant that is 40% of traffic needs dedicated shards or a sub-key; a single write-hot key needs salting into N sub-keys.
  3. Hash vs range: hash spreads evenly but loses range scans; range keeps scans cheap but sends every new time-ordered or counter-generated key to the last range. A URL shortener's codes are point lookups from a counter, so hash.
  4. Cost of other queries: "all messages by user X" now asks every shard, so either accept it (rare, admin) or maintain a second table keyed by user.
  5. Use many logical partitions mapped onto fewer machines, so adding capacity means moving whole partitions, not rehashing everything.

Learn more: Partitioning and sharding strategies and Secondary indexes in a sharded system.

Users are sharded by user id. How does login by email work?
Show answer

With a global lookup table keyed by email that maps to the user id: one lookup there, then one read on the user's shard. "Asking every shard on each login would put our p90 at the mercy of the slowest shard. Signup writes the email row first with a conditional insert, which also makes emails unique across shards, then the user row. Rare admin queries like 'users created last week' can scatter-gather or go to the warehouse instead of getting their own global index."

What’s happening
  1. Without the shard key, a query must go to all N shards and wait for the slowest; at 64 shards, every login is 64 calls.
  2. The lookup table users_by_email is itself partitioned, by email, so it scales like any other table.
  3. A conditional insert on the email row ("insert if absent") is how uniqueness is enforced when the user rows live on different shards.
  4. The lookup is a second write per change, and it can lag; an email change should update it before confirming to the user.
  5. Every global index costs a write on each update, so add them only for frequent, user-facing queries.

Learn more: Secondary indexes in a sharded system.

How do users upload large photos and videos?
Show answer

Straight to object storage with a presigned URL; only metadata touches our database. "The API authorises the upload, creates a pending row with a key we choose, and returns a presigned PUT URL valid for a few minutes, or a URL per part for multipart uploads. The bytes never pass through our servers. An upload event goes to a queue, and a worker checks the real size and type, scans the file, makes the variants and marks the row ready. Viewers get the file through a CDN."

What’s happening
  1. Proxying multi-megabyte bodies through API servers wastes their bandwidth and memory and ties up connections; object storage is built for exactly this traffic.
  2. The presigned URL signs the method, key and expiry. A presigned PUT doesn't limit size or content, so use a presigned POST policy with content-length-range, and always verify after upload.
  3. The pending/ready state on the metadata row means a half-finished upload is never shown, and a lifecycle rule aborts abandoned multipart uploads.
  4. Lifecycle rules also move old originals to cheaper storage classes, which matters at petabyte scale.
  5. Private files are served with short-lived signed CDN URLs, so the CDN still does the work.

Learn more: Object storage for files and media.

Kafka or a queue like SQS or RabbitMQ?
Show answer

A queue for jobs that one worker must do once; a log for business facts that several consumers need, in order per key, with replay. "Sending an email or transcoding a file is a job: competing consumers, retries, a dead-letter queue, delete on success. 'Order placed' is a fact that billing, search, analytics and fraud each need, possibly replayed to rebuild state, so it goes on a Kafka topic keyed by order id with one consumer group per service."

What’s happening
  1. Queue: the broker tracks each message; consumers ack and it's gone. Scale by adding workers. No replay: a new consumer starts from now.
  2. Log: an append-only partitioned sequence kept for a retention period (Kafka's default is 168 hours). Each group keeps an offset; every group reads everything; any group can rewind.
  3. Ordering and parallelism in a log are per partition: one consumer per partition within a group, so the partition count caps parallelism and must be planned for peak.
  4. Using a plain consumer group as a job queue breaks on slow or poison messages: one stuck message blocks its whole partition. Kafka 4.2's share groups add queue-style per-record acks, at the cost of ordering.
  5. Costs to name: a log's retention is rate × size × days × replicas of disk; a queue needs idempotent consumers and alerting on the age of the oldest message.

Learn more: Queues vs logs and Pub/sub and fan-out.

One message keeps crashing the consumer. What do you do?
Show answer

Bounded retries with exponential backoff and full jitter for transient errors, straight to a dead-letter queue for permanent ones, and to the DLQ after the last attempt either way. "That keeps the main queue moving, which for an ordered Kafka partition is essential. The DLQ keeps the original message, the error and the attempt count, alerts when it's not empty, and after a fix we redrive it at a controlled rate. All of this needs an idempotent consumer, because retries and redrives deliver the same message again."

What’s happening
  1. Classify: a timeout or a 503 is transient (retry); a validation error or a schema mismatch is permanent (retrying won't help).
  2. Full jitter: wait a random time between 0 and min(cap, base × 2^attempt), so retries from many consumers spread out instead of arriving together.
  3. Retry at one layer. Gateway, service and queue each retrying 3 times turns one failure into 27 or more attempts on a struggling dependency.
  4. Without an attempt limit, a poison message is redelivered every visibility timeout until retention deletes it: 11,520 times with SQS's defaults (30 s timeout, 4 days retention).
  5. Give the DLQ a longer retention than the source queue and an owner, or it's just a slower way to lose messages.

Learn more: Retries, dead-letter queues and poison messages.

Offset or cursor pagination for a feed?
Show answer

Cursor. "A feed changes while you read it, so offsets show duplicates when new posts arrive at the top and skip posts on deletes, and deep offsets get slower because the database reads and throws away every skipped row. I return { items, nextCursor }, where the cursor encodes the last item's sort key plus its id as a tie-breaker, and the query is WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT n. Offsets are fine for small, stable admin tables that need page numbers."

What’s happening
  1. With ?offset=10, three new posts push old posts 8–10 to positions 11–13, so page 2 repeats them.
  2. OFFSET 100000 makes the database walk 100,000 index entries to return 20; a cursor seeks straight to the position.
  3. A timestamp alone isn't a cursor: rows sharing the last timestamp are skipped with < or repeated with <=. The id makes the sort key unique.
  4. The cursor should be opaque (base64url of the key) so clients can't build their own and you can change the encoding.
  5. Ranked feeds need one more step: rank once per session, store the list of ids, and page through that snapshot.

Learn more: Pagination at the API level.

REST, GraphQL or gRPC?
Show answer

REST for public and partner APIs, GraphQL as the aggregation layer for many client screens, gRPC between internal services. "REST is callable by everyone, easy to rate-limit per endpoint, and its GETs are cacheable by browsers and CDNs. GraphQL gives each screen exactly the fields it needs in one round trip, at the cost of resolver batching, query cost limits and losing simple HTTP caching. gRPC gives a strict contract, compact Protobuf and deadlines that propagate, but it isn't browser-native, so it stays behind the gateway."

What’s happening
  1. REST's strengths are the web's: status codes, caching, proxies and tooling everybody understands.
  2. GraphQL's cost lands on the server: N+1 queries unless resolvers batch (DataLoader), depth and cost limits so one query can't ask for everything, and persisted queries if you want CDN caching back.
  3. GraphQL reports errors inside a 200 OK, so monitoring by status code sees 100% success; alert on the errors array per operation.
  4. gRPC runs over HTTP/2 streams, which brings the L4-balancer trap: balance per request, not per connection.
  5. A common shape: REST outside, a GraphQL BFF for the apps, gRPC inside.

Learn more: REST vs GraphQL vs gRPC and Reverse proxies and API gateways.

A million people watch a live score page. Polling or server-sent events?
Show answer

SSE. "Updates only flow from server to client, so I don't need WebSockets. Polling every 5 seconds would be 200,000 requests a second with only about one in six finding anything new, and it adds 2.5 seconds of delay on average. SSE holds a million connections instead, a handful of servers, and pushes each update once; EventSource reconnects by itself and sends Last-Event-ID so the server can replay what was missed. I'd serve it over HTTP/2 and send a heartbeat comment every 30 seconds."

rq-polling.mjs
// 1,000,000 viewers on a live-score page. Option 1: poll every 5 s. Option 2: SSE, 1 update every 30 s.
const viewers = 1_000_000;
const pollEvery = 5, updateEvery = 30;
const pollRps = viewers / pollEvery;
const useful = (1 / updateEvery) / (1 / pollEvery); // fraction of polls that find something new
console.log(`polling: ${pollRps.toLocaleString("en-US")} requests/s, ${(useful * 100).toFixed(0)}% of them useful`);
console.log(`polling: average extra delay ${pollEvery / 2} s, worst ${pollEvery} s`);
console.log(`SSE: ${viewers.toLocaleString("en-US")} open connections, ${(viewers / updateEvery).toLocaleString("en-US", { maximumFractionDigits: 0 })} messages/s pushed`);
console.log(`SSE at 200,000 connections per server: ${Math.ceil(viewers / 200_000)} servers (+1 spare)`);
Result
polling: 200,000 requests/s, 17% of them useful
polling: average extra delay 2.5 s, worst 5 s
SSE: 1,000,000 open connections, 33,333 messages/s pushed
SSE at 200,000 connections per server: 5 servers (+1 spare)
What’s happening
  1. Polling cost is viewers / interval: 1,000,000 / 5 = 200,000 requests a second, whether or not anything changed.
  2. With one update every 30 seconds, only 5 / 30 ≈ 17% of polls find news; the other 83% are pure overhead. The delay is half the interval on average.
  3. SSE turns that into a million idle connections and 33,333 pushed messages a second. Idle connections cost memory, not CPU; 200,000 per server (an assumption to load-test) is 5 servers plus a spare.
  4. Over HTTP/1.1, browsers allow about six connections per domain shared by all tabs, so serve SSE over HTTP/2 or HTTP/3, and turn off response buffering in proxies.
  5. WebSockets win when clients also send frequently, as in chat or games; long polling is the fallback where streaming is blocked.

Learn more: Real-time options: polling, long polling, SSE, WebSockets and Scaling WebSockets.

How do you send a chat message to users connected to different WebSocket servers?
Show answer

Through a pub/sub backplane: thin gateway servers hold the sockets and subscribe only to the channels their users are in; the chat service persists the message with a sequence number, then publishes it. "The backplane is at-most-once, so a client that was disconnected catches up from the store by sending its last sequence number. Presence is a heartbeat key with a TTL. I cap connections per gateway to limit the blast radius, and clients reconnect with jittered backoff so a failed gateway's users don't all reconnect at once."

What’s happening
  1. Gateways hold no business logic, so they rarely change and can be drained slowly during deploys.
  2. Persist before publishing: if the publish happened first and the write failed, some users would have seen a message that doesn't exist for anyone who reconnects.
  3. A gateway that crashes loses its copy of the published message; its users reconnect to another gateway and fetch everything after their last sequence number.
  4. The client orders by sequence number and drops duplicates, so the live push and the catch-up can overlap safely.
  5. Subscribing per channel (Redis sharded pub/sub, NATS) means a message reaches only the gateways with recipients, instead of every gateway.

Learn more: Scaling WebSockets and Design a chat system.

Which rate-limiting algorithm for a public API?
Show answer

A token bucket per API key, in Redis, updated atomically by a small Lua script. "Capacity sets the allowed burst and the refill rate the sustained limit, which matches how clients behave: a page load fires several calls, then nothing. It's two numbers per key. I'd remember that a bucket allows up to capacity plus rate × T in any span T. For daily quotas I'd add a window counter, for logins a sliding log because the limits are tiny and exactness matters, and rejections return 429 with Retry-After."

What’s happening
  1. Fixed window: one counter per minute; cheap, but a client can get 2× the limit across a window boundary (100 at 12:00:59 and 100 at 12:01:00).
  2. Sliding log: a timestamp per request; exact, but memory grows with the limit, so only for small limits.
  3. Sliding window counter: two fixed windows weighted by overlap; close to exact at the cost of two counters.
  4. Token bucket: smooth sustained rate plus bursts; leaky bucket shapes output to a fixed rate, more for traffic shaping than API quotas.
  5. Placement: coarse per-IP limits at the edge, per-key limits in the gateway. If Redis is down, general limits fail open and security limits (logins) fail closed.

Learn more: Rate limiting algorithms and Design a rate limiter.

How do you make "POST /payments" safe to retry?
Show answer

With an idempotency key: the client generates one per intended payment and sends it on every retry; the server claims it atomically before doing any work and stores the response under it. "A retry with the same key and body gets the stored response; a duplicate that arrives while the first is still running gets 409; the same key with a different body gets 422. The key is passed on to the card processor too, so a crash between charging and recording can't charge twice."

What’s happening
  1. A timeout doesn't mean the payment failed; the charge may have gone through and only the response was lost. Without a key, the retry is a second charge.
  2. The key is created once per user action (when checkout starts), not once per HTTP attempt.
  3. "Look up the key, then do the work, then save the key" is a race: two concurrent requests both see "not there". Claim it with a unique insert or SET key NX first.
  4. Store a fingerprint of the request body with the key, so a reused key with different parameters is rejected rather than silently answered with someone else's result.
  5. Keys expire (about 24 hours is common), so clients must stop retrying well before that, and reconciliation covers anything older.

Learn more: Idempotency keys for APIs and Design a payment system.

UUID v4, UUID v7 or Snowflake for primary keys?
Show answer

UUID v7 for most services, Snowflake when I need 64-bit keys, and UUID v4 only where ids must be unguessable and aren't clustered keys. "v7 is time-ordered, so inserts append to the right edge of the index instead of touching random pages, needs no coordination, and is built into PostgreSQL 18. Snowflake packs 41 bits of milliseconds, 10 bits of worker and 12 bits of sequence into 64 bits; the two things to get right are unique worker ids and the clock going backwards."

rq-snowflake.mjs
// How long does a Snowflake's 41-bit millisecond timestamp last, and how many IDs per worker?
const span = 2 ** 41; // milliseconds
console.log(`41 bits of ms: ${(span / 1000 / 86_400 / 365.25).toFixed(1)} years from the custom epoch`);
const twitterEpoch = 1288834974657; // 2010-11-04, Twitter's published Snowflake epoch
console.log(`Twitter's epoch runs out on ${new Date(twitterEpoch + span).toISOString().slice(0, 10)}`);
console.log(`12-bit sequence: ${2 ** 12} IDs per ms per worker = ${(2 ** 12 * 1000).toLocaleString("en-US")}/s`);
console.log(`10-bit worker id: ${2 ** 10} workers`);
Result
41 bits of ms: 69.7 years from the custom epoch
Twitter's epoch runs out on 2080-07-10
12-bit sequence: 4096 IDs per ms per worker = 4,096,000/s
10-bit worker id: 1024 workers
What’s happening
  1. 41 bits of milliseconds last about 69.7 years from the custom epoch; with Twitter's 2010 epoch that's mid-2080.
  2. The 12-bit sequence allows 4,096 ids per millisecond per worker, about 4 million a second; the 10-bit worker field allows 1,024 workers. The layout is a budget you can re-split.
  3. Two processes with the same worker id issue identical ids in the same millisecond. Assign ids from something that guarantees uniqueness: a StatefulSet ordinal or a lease in etcd.
  4. A Snowflake is around 2^61, beyond JavaScript's Number.MAX_SAFE_INTEGER (2^53 − 1), so send it as a string in JSON.
  5. UUID v4 as a clustered primary key scatters inserts across the index; time-ordered ids are worse under range partitioning, where every new row lands in the last range.

Learn more: Unique ID generation.

How long must the codes be for 100 M new short links a day for ten years?
Show answer

Seven base62 characters from a counter: 365 billion codes fit in 3.52 trillion, about 10% used. "Six characters hold only 56.8 billion. I'd generate from a counter handed out in ranges, scrambled with a keyed permutation so codes aren't enumerable, because random codes would need a uniqueness check and retries, and by the end about one insert in ten would clash."

rq-codes.mjs
// How many base62 characters for 100M new codes a day, for 10 years, from a counter?
const total = 100e6 * 365 * 10;
let len = 1;
while (62 ** len < total) len++;
console.log(`codes needed: ${(total / 1e9).toFixed(0)} billion`);
console.log(`${len - 1} chars: ${(62 ** (len - 1) / 1e9).toFixed(1)} billion (too few)`);
console.log(`${len} chars: ${(62 ** len / 1e12).toFixed(2)} trillion, ${((total / 62 ** len) * 100).toFixed(1)}% used`);
// Random codes instead: chance that a new code clashes when the table is that full.
console.log(`random codes at the end: 1 insert in ${Math.round(62 ** len / total)} clashes`);
Result
codes needed: 365 billion
6 chars: 56.8 billion (too few)
7 chars: 3.52 trillion, 10.4% used
random codes at the end: 1 insert in 10 clashes
What’s happening
  1. Total codes: 100 M × 365 × 10 = 365 billion.
  2. 62^6 = 56.8 billion is too few; 62^7 = 3.52 trillion fits with room, 10.4% used.
  3. A counter never collides: each app server takes a range (say 1,000 ids) from an allocator and encodes them. One allocator call per thousand links.
  4. Random 7-character codes collide with probability equal to the fraction already used: about 1 in 10 at the end of ten years. That works with a unique constraint and a retry loop, but every retry is another read on the write path.
  5. Base62 is case-sensitive; a case-insensitive column collation (MySQL's default) treats aB3 and Ab3 as equal. Use a binary collation, or a case-free alphabet if codes are typed or read aloud.

Learn more: Base62 and short codes and Design a URL shortener.

A crawler must remember 1 billion seen URLs. Why a Bloom filter, and how big?
Show answer

About 1.1 GiB for a 1% false-positive rate, against about 75 GiB to store the URLs themselves. "A Bloom filter never says 'no' wrongly, so a 'not seen' answer is final and needs no further lookup; a 'maybe' goes to the real URL store. Each extra 10× in accuracy costs about 4.8 more bits per item. The costs: I can't delete, I must size it up front, and every 'maybe' still needs the real check."

rq-bloom.mjs
// Sizing a Bloom filter: n items, target false-positive rate p.
// Optimal bits m = -n ln p / (ln 2)^2, optimal hash count k = (m / n) ln 2.
const size = (n, p) => {
  const m = Math.ceil((-n * Math.log(p)) / Math.LN2 ** 2);
  const k = Math.round((m / n) * Math.LN2);
  return { bitsPerItem: m / n, k, gib: m / 8 / 2 ** 30 };
};
for (const p of [0.01, 0.001]) {
  const { bitsPerItem, k, gib } = size(1e9, p);
  console.log(`1B items at ${p * 100}%: ${bitsPerItem.toFixed(2)} bits/item, k = ${k}, ${gib.toFixed(2)} GiB`);
}
// Compare: storing the items themselves, 1B URLs averaging 80 bytes.
console.log(`exact set of URLs: ${(1e9 * 80 / 2 ** 30).toFixed(1)} GiB before any index overhead`);
Result
1B items at 1%: 9.59 bits/item, k = 7, 1.12 GiB
1B items at 0.1%: 14.38 bits/item, k = 10, 1.67 GiB
exact set of URLs: 74.5 GiB before any index overhead
What’s happening
  1. The optimal size is m = −n ln p / (ln 2)² bits: about 9.59 bits per item for 1%, whatever the item's size.
  2. 1 billion × 9.59 bits ≈ 1.12 GiB, with k = 7 hash functions. At 0.1% it's 14.38 bits per item and 1.67 GiB.
  3. Storing the URLs exactly (80 bytes each on average, an assumption) is 74.5 GiB before any index, too much to keep in memory on every crawler node.
  4. False positives mean "skip a URL we never actually crawled" for 1% of new URLs, unless the "maybe" is checked against the URL store; false negatives would be a bug.
  5. Other places it fits: LSM-tree SSTable lookups, and keeping lookups for non-existent keys away from a database. For deletes use a counting or cuckoo filter.

Learn more: Bloom filters and Design a web crawler.

Unique visitors per page, or the most frequent queries: which sketch?
Show answer

HyperLogLog for counting distinct things, count-min sketch for how often each thing occurs. "Unique visitors per page per day is a cardinality question: a HyperLogLog is at most 12 KB with about 0.8% error, and weekly uniques come from merging daily sketches. Trending queries or top pages are a frequency question: a count-min sketch with a small heap for the top k, accurate for heavy hitters and useless for rare items, which is the right way round for 'trending'. Neither is for billing."

What’s happening
  1. HyperLogLog hashes each item and keeps, per register, the longest run of leading zeros seen. Its update is a max, so duplicates change nothing, which makes it safe with at-least-once delivery.
  2. Merging HyperLogLogs takes the register-wise max, so day sketches combine into weeks without re-reading events.
  3. Count-min keeps d rows of w counters; each item increments one counter per row, and its estimate is the minimum. It only over-counts, by at most εN with probability 1 − δ.
  4. Count-min adds on every delivery, so duplicates inflate it; dedupe upstream if retries are common.
  5. Exact sets per page would need memory proportional to visitors; across 10 million pages that's the thing the sketches avoid.

Learn more: HyperLogLog and count-min sketch.

How do you find restaurants within 500 m with a geohash, and what's the classic bug?
Show answer

Pick the precision whose cells are at least the radius (length 6, about 1.2 × 0.6 km at the equator), query that cell plus its 8 neighbours, then filter by real distance and rank. "The bug is searching only the user's own cell: two points a metre apart across a cell edge can share no prefix at all, so the nearest place can be in a neighbouring cell. For uneven density I'd use a quadtree that splits at about 100 places per leaf."

What’s happening
  1. Each geohash character narrows the cell; sharing a prefix guarantees closeness, but not sharing one guarantees nothing.
  2. The 3×3 block only covers every point within the smaller cell dimension of the user: in Toronto at length 6, 611 m. If the k-th result is farther than that, widen to the next ring or a shorter prefix.
  3. In Redis, GEOSEARCH does this for you over a sorted set whose score is a 52-bit geohash.
  4. Places are read-heavy and almost static: build the index ahead of time and keep a copy on every search server. Drivers move every few seconds: keep current locations in memory, sharded by city, and accept rebuilding from the update stream.
  5. Fixed cells give huge result sets downtown and empty ones in the countryside; a quadtree adapts by splitting crowded boxes.

Learn more: Geohashing and quadtrees and Design nearby places search.

How do you receive webhooks reliably?
Show answer

Verify, dedupe, enqueue, answer 2xx quickly; do the work later from our own queue. "The endpoint reads the raw body, checks the HMAC signature in constant time and the timestamp within about five minutes, inserts the event id with a unique constraint, puts the event on our queue and returns. A worker processes it idempotently and never lets an older event overwrite newer state. Because providers deliver at least once, out of order, and eventually give up, a reconciliation job lists recent objects through their API and fills gaps."

What’s happening
  1. Providers time out quickly (GitHub expects a response within 10 seconds), so slow processing in the handler means retries and duplicates.
  2. Verify the signature over the raw bytes; a JSON body parser that runs first breaks verification because re-serialised JSON isn't byte-identical.
  3. Dedupe on the provider's event id, which stays the same across its retries.
  4. Out-of-order delivery: compare versions or fetch the current object from the provider before applying a change.
  5. Exempt the route from CSRF middleware, and alert when verification failures spike (a rotated secret, or an attack).

Learn more: Webhooks.

#Distributed systems questions

Added

These questions are the ones that separate "I've used a database" from "I know what it promises". They cover partial failure, CAP and PACELC, consistency models, quorums, clocks, consensus, distributed transactions, idempotency and CRDTs, and then the reliability side from chapter 9: timeouts and retries, circuit breakers and bulkheads, degradation, observability, deployments and disaster recovery.

The mental model that ties them together: every message might be lost, delayed or duplicated, and every clock might be wrong. Almost every answer here is a way of living with that sentence. Timeouts guess at failure, so retries need idempotency. Clocks disagree, so ordering uses logical clocks or sequence numbers. Nodes can be paused and come back, so leases need fencing tokens. When an answer doesn't account for the sentence, that's where the interviewer's follow-up will land.

Service A calls service B and times out. Did B do the work?
Show answer

A can't know: a lost request, a lost reply and a slow server all look the same from A's side. "So I give every call a timeout, retry only with backoff and jitter and only if the operation is idempotent (with an idempotency key if it isn't naturally), put a circuit breaker in front of B so a slow B doesn't take A down, and where the outcome matters, like a payment, I query B for the result by the key instead of guessing."

What’s happening
  1. Partial failure is the defining property of distributed systems: some parts work while others don't, and you can't always tell which.
  2. A timeout is a guess, not a failure detector. Too short and healthy-but-slow nodes are declared dead (more failovers, more retries, more load); too long and users wait on nodes that really are gone.
  3. Retrying without idempotency is how the classic double charge happens: attempt 1 succeeds after the timeout, attempt 2 starts a second charge.
  4. Wall clocks can't order events across machines either; they disagree by milliseconds and can jump.
  5. The eight fallacies of distributed computing (the network is reliable, latency is zero, and so on) are a checklist for what your design assumes.

Learn more: Why distributed systems are hard and Idempotency and exactly-once.

Is your design CP or AP?
Show answer

Different parts choose differently, and I'd say which and why. "Booking a seat is CP: the inventory has one primary with majority writes, and if a region can't reach it, booking there fails with a retry message rather than risking a double booking. Browsing, search and the 'interested' counter are AP: served from regional replicas and caches that may be slightly stale. And PACELC adds the normal case: without a partition the CP part costs a few milliseconds per write for replication across zones, which is why I don't replicate it synchronously across regions."

What’s happening
  1. CAP only applies during a partition: then a system either refuses some requests to stay consistent (CP) or answers them with possibly stale data (AP).
  2. "Pick two of three" is the wrong phrasing; you can't choose to have no partitions, so "CA" only describes a single node.
  3. CAP's A is strict: every non-failed node must answer. A CP system that serves 99.99% of requests is still not "available" in the CAP sense.
  4. PACELC: if Partition, choose A or C; Else, choose Latency or Consistency. The else-branch is where most of the everyday cost lives.
  5. Most real stores are tunable per request (MongoDB write concerns, Cassandra consistency levels, DynamoDB's ConsistentRead), so describe settings rather than labels.

Learn more: CAP and PACELC in a design discussion.

A user saves their profile, refreshes, and sees the old one. Why, and what's the fix?
Show answer

The write went to the primary and the refresh was served by a replica that hadn't applied it yet: a read-your-writes violation caused by normal replication lag. "The simplest fix is to read the user's own profile from the primary for a short window after they write it. Better: the write returns a version or log position, the session keeps it, and reads go to a replica that has reached that position or fall back to the primary. Other users' profiles can still come from any replica."

What’s happening
  1. Asynchronous replicas are always slightly behind; nothing is broken, it's the model.
  2. Read-your-writes is a per-user guarantee: you see your own writes. It doesn't promise anything about other users' writes.
  3. Monotonic reads is the related one: never go backwards. A count shown as 12, then 14, then 13 means the reads hit replicas with different lag; pin the session to one replica, or carry "seen at least version 14".
  4. Linearizability (every read sees the latest write, in real-time order) would fix both but costs a round trip to a quorum or the leader on every read.
  5. Linearizable is not serializable: linearizability is about single operations on single objects, serializability about transactions over many objects; strict serializability is both.

Learn more: Consistency models and Replication: leader-follower, multi-leader and leaderless.

How do you choose N, R and W for a replicated store?
Show answer

N from durability and failure domains, then R and W from the read/write mix and the freshness the feature needs. "Three replicas in three zones survives a zone outage. For reads that must see the latest acknowledged write I need R + W > N, so with N = 3 it's 2 and 2: both reads and writes survive one replica down. If stale reads are fine, W = 1, R = 1 is the fastest and most available. And R + W > N gives fresh reads, not linearizability."

rq-quorum.mjs
// For N replicas, which (R, W) give fresh reads (R + W > N), and how many replica
// failures can reads and writes each survive?
for (const N of [3, 5]) {
  console.log(`N = ${N}`);
  for (let W = 1; W <= N; W++) {
    for (let R = 1; R <= N; R++) {
      if (R + W !== N + 1 && !(R === 1 && W === 1)) continue; // the useful edge cases
      const fresh = R + W > N ? "fresh" : "may be stale";
      console.log(`  W=${W} R=${R}: ${fresh.padEnd(13)} writes survive ${N - W} down, reads survive ${N - R} down`);
    }
  }
}
Result
N = 3
  W=1 R=1: may be stale  writes survive 2 down, reads survive 2 down
  W=1 R=3: fresh         writes survive 2 down, reads survive 0 down
  W=2 R=2: fresh         writes survive 1 down, reads survive 1 down
  W=3 R=1: fresh         writes survive 0 down, reads survive 2 down
N = 5
  W=1 R=1: may be stale  writes survive 4 down, reads survive 4 down
  W=1 R=5: fresh         writes survive 4 down, reads survive 0 down
  W=2 R=4: fresh         writes survive 3 down, reads survive 1 down
  W=3 R=3: fresh         writes survive 2 down, reads survive 2 down
  W=4 R=2: fresh         writes survive 1 down, reads survive 3 down
  W=5 R=1: fresh         writes survive 0 down, reads survive 4 down
What’s happening
  1. Fresh means every read set overlaps every write set, which is R + W > N. The rows printed are the edge cases R + W = N + 1, plus W = R = 1 for comparison.
  2. Writes survive N − W replicas being down and reads survive N − R. With N = 3, W = R = 2 is the balanced choice: one failure tolerated on both paths.
  3. W = 3, R = 1 makes reads cheap but means any single replica failure blocks all writes, and every write waits for the slowest replica.
  4. With N = 5, W = R = 3 survives two failures on both paths; that's the only pair that does while staying fresh.
  5. The catches: concurrent writes still need a conflict rule, a failed write that reached one replica isn't rolled back, and with sloppy quorums (hinted handoff) the read and write sets may not overlap at all.

Learn more: Quorums: R + W > N and Design a distributed key-value store.

Why not order writes from different servers by timestamp?
Show answer

Because the servers' clocks disagree and can jump, so a write that really happened later can carry an earlier timestamp and be silently overwritten under last-write-wins. "If I only need a total order that respects causality, Lamport clocks give one. If I need to know whether two writes conflicted, I need version vectors: they tell me whether one write had seen the other or whether they were concurrent, and concurrent writes go to a merge rule instead of a coin flip on clock skew."

rq-vclock.mjs
// Compare version vectors: before, after, equal, or concurrent?
function compare(a, b) {
  const ids = new Set([...Object.keys(a), ...Object.keys(b)]);
  let aBigger = false, bBigger = false;
  for (const id of ids) {
    const x = a[id] ?? 0, y = b[id] ?? 0;
    if (x > y) aBigger = true;
    if (y > x) bBigger = true;
  }
  if (aBigger && bBigger) return "concurrent";
  if (aBigger) return "a after b";
  if (bBigger) return "a before b";
  return "equal";
}
const cases = [
  [{ A: 1 }, { A: 2 }],
  [{ A: 2, B: 1 }, { A: 2 }],
  [{ A: 2 }, { B: 1 }],
  [{ A: 3, B: 1 }, { A: 2, B: 2 }],
];
for (const [a, b] of cases) console.log(`${JSON.stringify(a)} vs ${JSON.stringify(b)}: ${compare(a, b)}`);
Result
{"A":1} vs {"A":2}: a before b
{"A":2,"B":1} vs {"A":2}: a after b
{"A":2} vs {"B":1}: concurrent
{"A":3,"B":1} vs {"A":2,"B":2}: concurrent
What’s happening
  1. Compare version vectors slot by slot. If every slot of a is ≤ b's and they're not equal, a happened before b and b can replace it.
  2. {A:2, B:1} vs {A:2}: the first has seen one more write on B, so it is the later version.
  3. {A:2} vs {B:1}: each has something the other hasn't, so they're concurrent: neither can be thrown away.
  4. {A:3, B:1} vs {A:2, B:2}: concurrent again; a store returns both as siblings or merges them.
  5. Use one entry per replica (version vectors), not per client, or the vectors grow without bound. Where real time matters, Spanner bounds clock error and waits it out.

Learn more: Time and ordering: Lamport and vector clocks.

Why do consensus clusters have 3 or 5 nodes, not 4?
Show answer

Because a majority of 4 is 3, so 4 nodes survive only one failure, the same as 3, while costing more and splitting worse. "Three nodes tolerate one failure, five tolerate two. An even number adds a machine that can fail and makes every write wait for one more acknowledgement, and a 2 + 2 network split leaves neither side with a majority, so the whole cluster stops."

rq-majority.mjs
// Consensus cluster sizes: votes needed for a majority, and failures tolerated.
for (let n = 1; n <= 7; n++) {
  const majority = Math.floor(n / 2) + 1;
  console.log(`${n} node${n > 1 ? "s" : " "}: majority ${majority}, tolerates ${n - majority} down`);
}
Result
1 node : majority 1, tolerates 0 down
2 nodes: majority 2, tolerates 0 down
3 nodes: majority 2, tolerates 1 down
4 nodes: majority 3, tolerates 1 down
5 nodes: majority 3, tolerates 2 down
6 nodes: majority 4, tolerates 2 down
7 nodes: majority 4, tolerates 3 down
What’s happening
  1. Raft (etcd) and ZAB (ZooKeeper) commit and elect with a majority: floor(n / 2) + 1 votes.
  2. Tolerated failures are n − majority: 1 for 3 and 4 nodes, 2 for 5 and 6, 3 for 7.
  3. Two majorities always overlap in at least one node, and each node votes once per term, which is what prevents two leaders being elected in the same term.
  4. Bigger clusters make writes slower (more acknowledgements to wait for) without making reads faster, so 3 or 5 across zones is the usual size.
  5. Use the coordination service for small, critical state (leader leases, config, locks with fencing), not as a general database.

Learn more: Leader election and consensus.

How do you elect a leader and avoid split brain?
Show answer

Take a lease on a key in etcd or ZooKeeper, keep renewing it, and attach a fencing token, the lease revision, to everything the leader does. "If the leader dies or is partitioned away, the lease expires and another instance wins. Split brain can still happen if the old leader is paused, say by GC, and doesn't notice its lease has gone, so the downstream system rejects any token older than the newest it has seen. A lock without fencing is only a hint."

What’s happening
  1. The consensus layer guarantees only one lease holder at a time from its own point of view; it can't stop a paused process from acting on an old belief.
  2. Timeline: leader A holds token 33, pauses for 45 s, its lease expires, B takes the lease with token 34 and writes. A wakes up and writes with 33; storage has seen 34, so it rejects 33.
  3. If the protected resource can't check tokens, make the operation idempotent or conditional instead ("update only if version = 41").
  4. Redis locks with a TTL are fine for efficiency (avoid duplicate work) and unsafe for correctness (moving money, writing files) without fencing.
  5. The same idea appears as epochs in database failover: promotion bumps an epoch and the old primary is fenced so it can't accept writes.

Learn more: Leader election and consensus and Design a distributed job scheduler.

Order, payment and stock live in three services. How do you keep them consistent?
Show answer

A saga, not two-phase commit: a sequence of local transactions with a compensation for each step, run by an orchestrator. "The order is created as PENDING, stock is reserved, payment is authorised, then captured, and the order is CONFIRMED. If a step fails, the compensations run backwards: void the authorisation, release the reservation, cancel the order. Each step is idempotent, keyed by order id and step, because the orchestrator retries, and every service publishes its events through an outbox."

What’s happening
  1. 2PC holds locks across services until the coordinator decides; if the coordinator dies after the votes, participants are stuck, and the availability of all three services is tied together.
  2. A saga gives up isolation: other parts of the system can see the PENDING order, so the UI shows it as "processing".
  3. Order the steps from "likely to fail and cheap to undo" to "impossible to undo". Charging the card is the pivot; emails and notifications go last, because they can only be followed by an apology, never undone.
  4. If a compensation fails, it's retried with backoff until it succeeds (it's idempotent), and after repeated failures it goes to a dead-letter queue and a human.
  5. Orchestration (one coordinator owns the state) is easier to debug than choreography (services react to each other's events) once there are more than a couple of steps.

Learn more: Distributed transactions: two-phase commit, sagas and the outbox.

What problem does the transactional outbox solve?
Show answer

The dual write: updating the database and publishing an event as two separate steps means a crash between them either loses the event or publishes one for a change that rolled back. "With an outbox, the service writes the business row and an event row in the same local transaction. A relay reads the outbox table, or the database's change log, and publishes to the broker, retrying until it succeeds. Delivery becomes at-least-once, so consumers dedupe on the event id."

What’s happening
  1. "Commit, then publish": a crash after the commit loses the event, and downstream systems never hear about the order.
  2. "Publish, then commit": a crash or rollback after the publish announces an order that doesn't exist.
  3. The outbox makes both writes one atomic commit; the relay is the only thing that talks to the broker.
  4. Change data capture (Debezium reading the write-ahead log) is the same pattern without polling the table.
  5. The event id is generated once, in the outbox row, so it's the same on every redelivery: that's the dedup key.

Learn more: Distributed transactions: two-phase commit, sagas and the outbox and Pub/sub and fan-out.

Can you guarantee exactly-once processing?
Show answer

Not exactly-once delivery, no; what I build is at-least-once delivery plus idempotent effects, which gives exactly-once results. "Each message carries an id generated once by the producer. The consumer, in one database transaction, inserts that id into a processed-events table with a unique constraint and applies the effect; a redelivery hits the constraint and is skipped. For external calls I pass the message id as the idempotency key. Kafka-to-Kafka pipelines can use transactions to commit output and offsets atomically."

What’s happening
  1. A sender can't tell a lost message from a lost acknowledgement, so it must choose between possibly losing (at-most-once) and possibly duplicating (at-least-once).
  2. Checking the id and applying the effect in separate steps isn't enough: a crash between them either double-applies or loses the event.
  3. Dedupe on the producer's id, never on the broker's per-delivery handle, which changes between deliveries.
  4. Natural idempotency is cheaper still: a PUT of the full resource, or a conditional update (WHERE status = 'PACKED') that matches zero rows the second time.
  5. Keep processed ids at least as long as the broker can redeliver or you might replay.

Learn more: Idempotency and exactly-once and Delivery guarantees and ordering.

Three regions accept likes concurrently. How do you count them?
Show answer

A PN-counter CRDT per post: one increment and one decrement slot per region, merged by taking the maximum of each slot. "Each region only updates its own slots and ships its state to the others asynchronously; the merge is commutative, associative and idempotent, so order and duplicates don't matter, and the count is the sum. Last-write-wins on a plain number would lose concurrent increments. The like itself is a unique (user, post) row, so nobody likes twice."

What’s happening
  1. Merging takes max per slot, not the sum: two copies of {A: 4, B: 2} and {A: 3, B: 5} merge to {A: 4, B: 5} = 9, because each slot is one region's own tally seen at different times.
  2. Every region's count can be a few seconds behind the others; for likes that's fine.
  3. CRDTs can't enforce invariants: two regions each selling the last unit of stock merge to −1. Anything with "at most", "unique" or "never negative" needs coordination at write time, or escrow (each region gets its own share).
  4. They also carry metadata: a slot per replica, and tombstones for removed set elements that need garbage collection.
  5. Text editors use sequence CRDTs (Yjs, Automerge) on the same principle.

Learn more: Conflict resolution: last-write-wins and CRDTs and Design a collaborative document editor.

How do you stop a slow dependency from taking your service down?
Show answer

Timeouts with deadlines, retries at one layer with a budget, a circuit breaker per dependency, and a bulkhead per dependency sized with Little's law. "Each call's timeout sits just above the callee's p99, and the request carries a deadline so no hop keeps working after the user has gone. Retries are only for transient errors on idempotent calls, with full jitter and a budget of about 10% of traffic. The breaker opens on an error rate and fails fast to a fallback, and each dependency gets its own pool of slots so a hung one can only exhaust its own."

rq-bulkhead.mjs
// Little's law: requests in flight = arrival rate x time each one takes.
// Size a connection pool (bulkhead) per dependency from its peak rate and p99 latency.
const deps = [
  { name: "payments", rps: 400, p99: 0.8 },
  { name: "inventory", rps: 1_500, p99: 0.05 },
  { name: "recommendations", rps: 2_000, p99: 0.3 },
];
const headroom = 1.5;
const pools = {};
for (const d of deps) {
  const inFlight = d.rps * d.p99;
  pools[d.name] = Math.ceil(inFlight * headroom);
  console.log(`${d.name.padEnd(16)} ${String(inFlight).padStart(4)} in flight at p99 -> pool of ${pools[d.name]}`);
}
// Recommendations slows to 5 s per call. How many slots would it need now?
const needed = 2_000 * 5;
console.log(`recommendations at 5 s needs ${needed.toLocaleString("en-US")} slots; ` +
  `its pool of ${pools.recommendations} fills and ${((1 - pools.recommendations / needed) * 100).toFixed(0)}% of calls fail fast`);
Result
payments          320 in flight at p99 -> pool of 480
inventory          75 in flight at p99 -> pool of 113
recommendations   600 in flight at p99 -> pool of 900
recommendations at 5 s needs 10,000 slots; its pool of 900 fills and 91% of calls fail fast
What’s happening
  1. Little's law: in-flight requests = arrival rate × time each one takes. Payments at 400/s and 0.8 s p99 has 320 calls in flight; with 50% headroom, a pool of 480.
  2. Inventory is fast (50 ms), so it needs only 113 slots despite 1,500 calls a second.
  3. If recommendations slows from 0.3 s to 5 s, it would need 10,000 slots. Its bulkhead of 900 fills, and the excess, 91% of its calls, fails fast to the fallback instead of eating the threads payments needs.
  4. The circuit breaker goes closed → open on errors → half-open after a cool-down with a few trial calls → closed if they succeed.
  5. Retries multiply across layers (3 attempts at each of 3 layers is 27 calls on the bottom service), and without a budget they can keep a recovered service overloaded: a metastable failure.

Learn more: Timeouts, retries, circuit breakers and bulkheads.

The recommendations service is down. What does the product page show?
Show answer

The page still renders; the recommendations slot falls back to a cached, non-personalised list, or disappears. "Recommendations is non-critical, so it has a short timeout, a circuit breaker, and a fallback of bestsellers in the category refreshed hourly. Pricing is critical: if it's down we don't show a stale price, we show 'temporarily unavailable' on the buy button, because a wrong price is worse than none. Under overload, requests carry a criticality and recommendations is shed first so checkout keeps working."

What’s happening
  1. Classify every dependency as critical or non-critical for each page; non-critical ones never fail the page.
  2. A fallback must be cheaper and have fewer dependencies than what it replaces, or it becomes the next overload.
  3. "Serve stale" is fine for content, dangerous for permissions and prices. Say which data may be stale.
  4. Fallbacks that only run during incidents are untested code; exercise them with brownouts and game days.
  5. Alert when a breaker opens, so degraded mode is visible rather than silent for days.

Learn more: Graceful degradation and fallbacks.

How do you limit the blast radius of a bad deploy or a poison request?
Show answer

Cells and shuffle sharding: split customers into independent copies of the stack, and deploy cell by cell. "Each cell is a full stack behind a thin router keyed by tenant id, so a bad deploy, a poison request or an overloaded database hits one cell's customers, not everyone. Where customers share a pool of workers, shuffle sharding gives each one a random subset, so a customer that crashes every worker it touches takes down almost nobody else."

What’s happening
  1. Redundancy handles "a component dies"; isolation handles "a fault spreads". They're different problems.
  2. With 16 workers and shards of 4, there are C(16, 4) = 1,820 possible shards, so another customer shares all four of the poison customer's workers with probability 1 in 1,820, about 0.055%. Fixed groups of 4 would lose 25% of customers.
  3. Shuffle sharding needs client-side failover: customers who share some workers stay up by retrying on their healthy ones.
  4. Global state sneaks back in: one login database, one session Redis or one config service puts every cell back into one blast radius. List what's still shared and why it's safe.
  5. Keep the router and control plane minimal and statically stable, so the data plane keeps serving from cached config if the control plane fails.

Learn more: Failure modes and blast radius.

What would you put on the dashboard and what would page someone?
Show answer

Page on symptoms users feel, SLO burn rate on errors and latency at the edge; put causes like CPU, queue depth and restarts on dashboards. "Every service emits RED metrics per endpoint (rate, errors, duration as a histogram), USE metrics for resources, traces propagated with W3C traceparent through HTTP and message headers, and structured logs with the trace id. Alerts use multiwindow burn rates, and percentiles come from merged histograms, never from averaging each host's p99."

What’s happening
  1. "CPU above 80%" pages people for things users never notice, and teams learn to ignore pages. Symptoms are what matter: requests failing or slow.
  2. Burn rate is the error rate divided by the budgeted rate. 2% errors against a 99.95% SLO is a burn rate of 40; an hour of it uses about 5.6% of a 30-day budget.
  3. Averaging per-host p99s is wrong: percentiles don't add. Merge histograms, then compute the percentile.
  4. Keep metric labels low-cardinality (no user ids); put high-cardinality detail in traces and logs.
  5. Tail sampling for traces keeps all errors and slow requests and a small share of the rest.

Learn more: Observability: logs, metrics and traces.

Blue-green or canary?
Show answer

Canary for most releases, blue-green when I need an instant, whole-fleet switch, and feature flags to separate deploying code from releasing features. "A canary sends 1–5% of traffic to the new version next to a fresh baseline, compares errors and latency automatically, then widens in steps, cell by cell or region by region. Blue-green keeps a whole second environment and flips traffic at once, so rollback is fast but a bad change hits everyone the moment you flip. Either way, schema changes use expand and contract so any deploy can be rolled back."

What’s happening
  1. Canary limits exposure: a bad change hits a few percent of users, and automated analysis can roll it back before people notice.
  2. Small canaries need time to detect problems: in chapter 9's model (20,000 requests a minute, a 0.5% error rate), a 1% canary took a median of 24 minutes to catch a doubling of errors and missed it in 17 of 200 hour-long runs, while a 5% canary caught it every time in about 6 minutes. So risky changes get a larger canary or a longer bake.
  3. Blue-green limits rollback time, but both environments usually share one database, so a migration the old version can't read makes the instant rollback a fiction.
  4. Expand and contract: add the new column, write both, migrate readers, then remove the old one in a later release.
  5. Feature flags give a kill switch and percentage rollouts, at the cost of flag debt that must be cleaned up.

Learn more: Deployments: blue-green, canary and feature flags.

The business wants RPO 1 minute and RTO 15 minutes for a 10 TB database. Can backups do it?
Show answer

No. Restoring 10 TB takes hours, so the RTO needs a standby replica in another region that can be promoted; backups are still needed for a different failure. "RPO is how much data we may lose, RTO how long we may be down. Continuous replication gives an RPO of seconds and promotion takes minutes. But replication copies bad writes too, so I keep point-in-time recovery, nightly snapshots plus continuously shipped WAL, in a separate account with object lock, and I test restores to know the real RTO."

What’s happening
  1. A restore at 1 GB/s copies 10 TB in 10,000 GB / 1 GB/s = 10,000 s, about 2.8 hours, before log replay. That's an order of magnitude over 15 minutes, whatever the backup schedule.
  2. RTO is dominated by data volume ÷ restore speed; RPO by how often changes leave the primary. More frequent backups fix RPO, never RTO.
  3. A warm standby replica (asynchronous, cross-region) meets RPO 1 minute if replication lag stays under it, and RTO 15 minutes if promotion and DNS switching are automated.
  4. Logical damage (a bad migration, a deleted table, ransomware) replicates instantly, so point-in-time recovery from immutable backups is still required.
  5. Many teams automate every step of regional failover but keep the decision to fail over with a human, because a false alarm costs a real RPO and RTO.

Learn more: Disaster recovery: RPO, RTO and backups.

How do you secure this design?
Show answer

Walk the request path and name the check at each boundary. "At the edge: CDN with DDoS absorption and WAF, TLS 1.3, origin reachable only through the edge. At the gateway: authentication with short-lived tokens, rate limits, request size limits. In each service: authorisation against the specific object, tenant first and deny by default, because a valid token says nothing about which order you may read. Between services: mTLS and least-privilege identities. Data encrypted at rest with KMS keys, secrets in a secrets manager, and an audit log of privileged actions."

What’s happening
  1. AuthN answers "who are you?"; authZ answers "may you do this to this object?". Broken object-level authorisation is the top API flaw: /orders/123 must check the order belongs to the caller.
  2. "It's internal, so it doesn't need auth" is how one compromised service becomes a full breach. Treat the internal network as hostile.
  3. Server-side request forgery: a service that fetches user-supplied URLs must not be able to reach metadata endpoints or admin APIs.
  4. Rate limiting is not DDoS protection: volumetric attacks are dropped at the edge before TLS termination.
  5. Backups belong in a separate account with object lock, so a compromised admin can't delete them.

Learn more: Security in a design review and Frontend security.

#Case study rapid-fire

Added

The three backend case-study chapters work through seventeen prompts in full. Interviews draw from a longer list, and many of the others are a familiar design with one twist: a pastebin is a URL shortener whose values are large, an ad-click aggregator is a stream job whose counts are money, a live-comments system is a chat system with millions of readers per room. This topic gives each of those prompts a compact design: the paragraph you'd say once the requirements are agreed, the estimate that drives it, and the key decisions with the reason each one wins.

Use them the way a chess player uses openings: know the first ten moves cold, so your thinking goes into the position in front of you. In the interview you'd still run the full framework (requirements, estimates, API, data model, diagram, deep dives), and you'd expect the interviewer to push on exactly the decisions listed in each walkthrough. Every number below comes from stated assumptions or from a cited source, and every model was run; if your interviewer gives different numbers, re-run the arithmetic out loud rather than reusing these.

Design Pastebin

Users paste text and get a short link; pastes can expire; most are read a few times.

Show answer

A URL shortener whose values are too big for the hot store: short codes from a counter, the text in object storage, metadata in a database, and a CDN in front of reads. "At 2 million pastes a day it's about 70 writes and 700 reads a second at peak, and about 27 TB of text over five years. So the code generator is the URL shortener's (counter ranges, base62, scrambled), the metadata row holds the code, owner, size, expiry and the object key, and the content goes to object storage under that key, written before the row so a link never points at nothing. Reads go GET /p/{code} → CDN → service → object storage, with long cache TTLs because a paste never changes after creation; expiry is checked on read and a lifecycle job deletes expired objects."

rq-pastebin.mjs
// Pastebin. Assumptions: 2M new pastes a day, 10 KB average, 10 reads per paste,
// peak 3x, keep 5 years, 1 in 4 pastes expires within a month (not counted in storage).
const DAY = 86_400, perDay = 2e6, size = 10e3, readsPer = 10, peak = 3, years = 5;
const kept = perDay * 0.75;
console.log(`writes: ${(perDay / DAY).toFixed(0)}/s avg, ${(perDay / DAY * peak).toFixed(0)}/s peak`);
console.log(`reads:  ${(perDay * readsPer / DAY).toFixed(0)}/s avg, ${(perDay * readsPer / DAY * peak).toFixed(0)}/s peak`);
console.log(`content: ${(kept * size * 365 * years / 1e12).toFixed(1)} TB in ${years} years (object storage)`);
console.log(`metadata at 200 B/paste: ${(kept * 200 * 365 * years / 1e12).toFixed(2)} TB (database)`);
Result
writes: 23/s avg, 69/s peak
reads:  231/s avg, 694/s peak
content: 27.4 TB in 5 years (object storage)
metadata at 200 B/paste: 0.55 TB (database)
What’s happening
  1. Where the content lives is the main decision. A 10 KB average with a long tail (pastes of megabytes) doesn't belong in database rows; object storage holds bytes cheaply, the database holds a 200-byte row, about 0.55 TB over five years. Very small pastes could be inlined in the row as an optimisation.
  2. Immutability makes caching easy: a paste's content never changes, so the CDN can cache GET /p/{code} until its expiry, and the service is barely touched by popular pastes. Edits, if offered, create a new paste.
  3. Expiry: store expires_at, return 410 Gone after it, set cache TTLs no longer than the remaining lifetime, and let an object-storage lifecycle rule or a sweeper delete the bytes later. A quarter of pastes expiring within a month (an assumption) is why the storage estimate counts only 75%.
  4. Abuse is the real operational problem: pastes hold leaked credentials and malware. Limit paste size, rate-limit creation per IP and per account, scan new pastes, and support takedown by purging the CDN.
  5. What changes from the URL shortener: writes are bytes, not rows (an upload path with a size limit), and analytics usually aren't needed, so a CDN-cached response is fine where the shortener needed 302 with a short max-age.

Learn more: Design a URL shortener, Object storage for files and media and CDNs.

Design the analytics for a URL shortener (TinyURL analytics)

Link owners see clicks per day, unique visitors, top countries and referrers, within a few minutes.

Show answer

Keep analytics off the redirect path: the redirect publishes a click event and answers; a stream job aggregates; an OLAP store serves the owner's dashboard. "At chapter 10's scale that's about 11,600 clicks a second on average and 35,000 at peak, 300 GB of raw events a day. The redirect service fires an event (code, time, referrer, country from the IP, user agent) to a Kafka topic partitioned by code, without waiting. A stream job keeps per-code counters and flushes rollups every minute: clicks per code per minute and per day by country and referrer, plus a HyperLogLog per code per day for unique visitors. Raw events go to object storage for reprocessing. The dashboard reads the rollups from a columnar store such as ClickHouse or Druid."

rq-click-analytics.mjs
// Click analytics for a URL shortener: 1 billion redirects a day (chapter 10's 11,574/s average),
// 300-byte click events, peak 3x. Rollups per (code, minute) and per (code, day, country).
const clicks = 1e9, eventBytes = 300, peak = 3;
const perSec = clicks / 86_400;
console.log(`events: ${Math.round(perSec).toLocaleString("en-US")}/s avg, ${Math.round(perSec * peak).toLocaleString("en-US")}/s peak`);
console.log(`raw log: ${(clicks * eventBytes / 1e9).toFixed(0)} GB/day, ${(clicks * eventBytes * 365 / 1e12).toFixed(0)} TB/year before compression`);
// Assume 20M distinct codes get at least one click per day, each clicked in 5 distinct minutes
// and from 3 countries on average.
const activeCodes = 20e6;
console.log(`minute rollup rows/day: ${(activeCodes * 5 / 1e6).toFixed(0)}M; country-day rows: ${(activeCodes * 3 / 1e6).toFixed(0)}M`);
console.log(`unique visitors: HLL per code per day, ${(activeCodes * 1.5e3 / 1e9).toFixed(0)} GB at ~1.5 KB each (sparse)`);
Result
events: 11,574/s avg, 34,722/s peak
raw log: 300 GB/day, 110 TB/year before compression
minute rollup rows/day: 100M; country-day rows: 60M
unique visitors: HLL per code per day, 30 GB at ~1.5 KB each (sparse)
What’s happening
  1. Asynchronous by design: an UPDATE clicks = clicks + 1 per redirect would turn a 35,000-reads-a-second path into 35,000 writes a second on hot rows. If Kafka is down, the redirect still works; each redirect host spools click events to local disk and replays them when Kafka returns, so only a host that dies during the outage loses its spool, which the requirements accept.
  2. 302, not 301: a 301 is heuristically cacheable, so browsers would skip the shortener on repeat clicks and analytics would undercount. Chapter 10 answers 302 with a short private max-age; the Cache-Control header matters as much as the status code (Bitly answers 301 with private, max-age=90, as chapter 10 notes).
  3. Rollups, not raw queries: a billion raw rows a day is 110 TB a year before compression, but the dashboard needs about 100 M minute rows and 60 M country-day rows a day (assuming 20 M active codes, each clicked in about 5 distinct minutes and from 3 countries). Pre-aggregate on the dimensions the product shows.
  4. Unique visitors is count-distinct: a HyperLogLog per code per day, about 0.8% error, mergeable into weeks. At an assumed 1.5 KB per sparse sketch that's about 30 GB a day. Exact sets would be far bigger.
  5. Correctness details: dedupe events by id (at-least-once delivery inflates sums but not HyperLogLogs), bucket by event time with a watermark and allowed lateness, filter bots before counting, and rebuild rollups from the raw log when the logic changes.

Learn more: Design a URL shortener, Batch vs stream processing and HyperLogLog and count-min sketch.

Design Instagram

Upload photos, follow people, see a home feed, like and comment. Meta reported 3 billion monthly active users in September 2025; assume 500 M daily users for the estimate.

Show answer

The photo app from chapter 1 at ten times the users, plus the news feed from chapter 11: presigned uploads to object storage, asynchronous resizing, a CDN for every image, and a hybrid fan-out feed of post ids. "At 500 M daily users it's about 1,700 uploads, 174 K feed loads and 1.7 M image views a second at peak, 120 TB of new media a day and about 4.2 Tbit/s of image egress, so images can only come from a CDN. Upload: the client gets a presigned URL, sends the bytes to object storage, and a worker makes the sizes and formats and marks the post ready. Feed: on post, a fan-out service pushes the post id into each follower's cached timeline, except for accounts above a follower threshold, which are merged at read time. Reads hydrate ids from caches of posts and users. Likes and comments are counted asynchronously."

rq-instagram.mjs
// Instagram-scale photo sharing: chapter 1's photo-app ratios at 10x the users (assumption: 500M DAU).
// Per user per day: 0.1 uploads (2.4 MB stored with resized copies), 10 feed loads (20 KB), 100 photo views (300 KB).
const dau = 500e6, DAY = 86_400, peak = 3;
const qps = (perUser) => (dau * perUser) / DAY;
const f = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${Math.round(n)}`);
console.log(`uploads: ${f(qps(0.1))}/s avg, ${f(qps(0.1) * peak)}/s peak`);
console.log(`feed loads: ${f(qps(10))}/s avg, ${f(qps(10) * peak)}/s peak`);
console.log(`photo views: ${f(qps(100))}/s avg, ${f(qps(100) * peak)}/s peak`);
console.log(`new media: ${(dau * 0.1 * 2.4e6 / 1e12).toFixed(0)} TB/day, ${(dau * 0.1 * 2.4e6 * 365 / 1e15).toFixed(1)} PB/year`);
console.log(`image egress at peak: ${(qps(100) * peak * 300e3 * 8 / 1e12).toFixed(1)} Tbit/s (CDN)`);
Result
uploads: 579/s avg, 1.7K/s peak
feed loads: 57.9K/s avg, 173.6K/s peak
photo views: 578.7K/s avg, 1.74M/s peak
new media: 120 TB/day, 43.8 PB/year
image egress at peak: 4.2 Tbit/s (CDN)
What’s happening
  1. Media path: bytes never touch the API servers. One original plus resized variants (thumbnails, feed size, full size, in AVIF/WebP and JPEG) is about 2.4 MB per upload in chapter 1's assumptions; 43.8 PB a year makes lifecycle rules to colder storage classes a real cost lever.
  2. Egress: 1.74 M views a second at 300 KB each is 4.2 Tbit/s at peak. CDN hit ratio and image size (serve the size the device displays) are the levers; every percent of hit ratio is tens of Gbit/s of origin traffic.
  3. Feed: fan-out on write for most authors makes reads one cache lookup; celebrities are pulled at read time so one post doesn't become 100 M writes. Timelines hold ids (a few KB per user), hydrated on read so deletes and privacy are enforced at read time.
  4. Metadata store: posts, follows and likes are partitioned (posts by post id, follows by follower and by followee as two tables, likes by user), because 1,700 posts a second plus their likes outgrow one primary.
  5. Counters: like and comment counts are derived from events and written once a second per post, with sharded counters for viral posts, so a celebrity post doesn't create a hot row.

Learn more: Back-of-the-envelope estimation, Design a news feed and Design a comments and likes system.

Design Google Maps map tiles

Serve the map images (or vector data) for any place at any zoom, fast, worldwide.

Show answer

Pre-render the low zooms, render the high zooms on demand and cache them, and let a CDN serve almost everything, because tiles are immutable per map version. "The world is a pyramid: zoom z has 4^z tiles of 256 × 256 pixels, so zoom 18 alone is 69 billion tiles and three quarters of all tiles up to it. Most of those are ocean or empty and can share one blank tile, and most of the rest are never requested. So zooms 0 to about 12 are rendered ahead of time, deeper tiles are rendered on first request and stored in object storage, and every tile URL contains the map version, /v42/{z}/{x}/{y}, so the CDN caches it for ever and a new map version is a new set of URLs. Modern clients fetch vector tiles and draw them on the GPU, which makes one tile serve every style, rotation and label language."

rq-tiles.mjs
// Map tiles: zoom z has 4^z tiles of 256x256 px. How many tiles to pre-render, and how big?
// Assumptions: 15 KB per raster tile on average; ~70% of tiles at high zoom are ocean or empty
// and are served from one shared "blank" tile instead of being stored.
const avgKB = 15, emptyShare = 0.7;
let total = 0;
for (const z of [0, 5, 10, 15, 18, 20]) console.log(`zoom ${String(z).padStart(2)}: ${(4 ** z).toLocaleString("en-US")} tile${z ? "s" : ""}`);
for (let z = 0; z <= 18; z++) total += 4 ** z;
console.log(`zoom 0-18: ${(total / 1e9).toFixed(1)} billion tiles`);
const stored = total * (1 - emptyShare);
console.log(`stored after dropping empty tiles: ${(stored / 1e9).toFixed(1)} billion, ${(stored * avgKB * 1e3 / 1e15).toFixed(2)} PB`);
console.log(`share of all tiles at zoom 18 alone: ${((4 ** 18 / total) * 100).toFixed(0)}%`);
Result
zoom  0: 1 tile
zoom  5: 1,024 tiles
zoom 10: 1,048,576 tiles
zoom 15: 1,073,741,824 tiles
zoom 18: 68,719,476,736 tiles
zoom 20: 1,099,511,627,776 tiles
zoom 0-18: 91.6 billion tiles
stored after dropping empty tiles: 27.5 billion, 0.41 PB
share of all tiles at zoom 18 alone: 75%
What’s happening
  1. The pyramid: tile counts quadruple per zoom, so the deepest level dominates: zoom 0–18 is 91.6 billion tiles, 75% of them at zoom 18. Pre-rendering everything would be about 27.5 billion stored tiles and 0.41 PB even after dropping empty ones (assuming 15 KB a tile and 70% empty, roughly the share of the Earth covered by water; both assumptions).
  2. Render on demand for deep zooms: requests follow people, so cities are rendered and cached quickly while the open ocean never is. Render in metatiles (say 8 × 8 tiles at once) to share the work of reading the geodata.
  3. Cache keys: {version}/{z}/{x}/{y} (plus style for raster tiles) is a perfect CDN key: no query strings, no personalisation, immutable. A map data update bumps the version, or invalidates just the tiles whose area changed.
  4. Vector vs raster: vector tiles (typically 512 px, so their zoom levels are one lower, per the OpenStreetMap wiki) are smaller and let the client restyle, rotate and label in any language; raster tiles are simpler for old clients. Traffic and other live layers are separate overlay tiles with short TTLs.
  5. Search and routing are different systems: geocoding is a search index, routing is a graph with precomputed shortcuts. Don't let the tile design swallow the whole interview unless the prompt is only tiles.

Learn more: CDNs, Object storage for files and media and Geohashing and quadtrees.

Design a metrics and monitoring system

Collect metrics from every host and service, show dashboards, alert within a minute.

Show answer

Agents push samples (or scrapers pull them) into a sharded time-series store, recent data stays at full resolution, old data is rolled up, and alerting runs as its own always-on evaluator. "With 20,000 hosts and 500 series each, that's 10 million active series and about 670,000 samples a second at a 15-second interval. A TSDB compresses each sample to 1–2 bytes (the Prometheus documentation's figure), so raw data is about 86 GB a day: 15 days at full resolution, then 5-minute rollups of count, sum, min and max kept for 13 months. Ingest is sharded by series hash, each shard replicated; queries fan out to the shards and merge. Alert rules are evaluated every 15–60 seconds by a separate service that reads recent data from memory, so alerting keeps working when dashboards are slow."

rq-metrics.mjs
// A metrics system. Assumptions: 20,000 hosts x 500 active series each, scraped every 15 s,
// 1.5 bytes per stored sample (Prometheus documents 1-2 bytes), raw kept 15 days,
// then 5-minute rollups (count, sum, min, max: ~4 values) kept 13 months.
const series = 20_000 * 500, interval = 15, bytes = 1.5;
const samplesPerSec = series / interval;
const perDay = samplesPerSec * 86_400 * bytes;
console.log(`active series: ${(series / 1e6).toFixed(0)}M, ingest ${Math.round(samplesPerSec).toLocaleString("en-US")} samples/s`);
console.log(`raw: ${(perDay / 1e9).toFixed(0)} GB/day, ${(perDay * 15 / 1e12).toFixed(1)} TB for 15 days`);
const rollupPerDay = series * (86_400 / 300) * 4 * bytes;
console.log(`5-min rollups: ${(rollupPerDay / 1e9).toFixed(1)} GB/day, ${(rollupPerDay * 395 / 1e12).toFixed(1)} TB for 13 months`);
// A dashboard panel for one metric over 7 days across 10,000 series at 15 s resolution:
console.log(`naive 7-day panel over 10,000 series: ${(10_000 * 7 * 86_400 / 15 / 1e6).toFixed(0)}M samples to read`);
Result
active series: 10M, ingest 666,667 samples/s
raw: 86 GB/day, 1.3 TB for 15 days
5-min rollups: 17.3 GB/day, 6.8 TB for 13 months
naive 7-day panel over 10,000 series: 403M samples to read
What’s happening
  1. Cardinality is the scaling unit, not hosts: every distinct label combination is a series. A label with user ids or request ids explodes the series count, so labels are low-cardinality by policy and high-cardinality detail goes to logs and traces.
  2. Storage: append-only, time-ordered, compressed blocks (delta-of-delta timestamps, XOR-compressed values) with a write-ahead log; old blocks compacted and moved to object storage. retention × samples/s × bytes/sample is the whole sizing formula.
  3. Rollups must keep the maths right: store count, sum, min and max (and histograms for latency) so averages and percentiles can be recomputed from rollups. Averaging averages, or averaging per-host p99s, is wrong.
  4. Queries: a naive 7-day panel over 10,000 series reads 403 M samples, so long ranges use rollups and dashboards cache recent results. Recording rules precompute expensive expressions.
  5. Alerting is the critical path: evaluate rules close to the data, deduplicate and route notifications (grouping, silences, escalation), and monitor the monitoring system from outside, because it can't page about its own death.

Learn more: Time-series, wide-column and graph stores and Observability: logs, metrics and traces.

Design an email service like Gmail

Receive, store, search and send mail for a very large number of mailboxes.

Show answer

Inbound SMTP servers behind MX records accept mail, a pipeline filters and classifies it, messages land in per-mailbox storage with bodies in blob storage and metadata in a partitioned store, a per-user search index serves search, and clients sync by change log. "With an assumed 1 billion mailboxes receiving 40 messages a day, that's 40 billion messages a day, about 460,000 a second, and around 2.8 PB of new storage a day even after storing shared attachments once. So the mailbox is partitioned by user id: metadata (labels, read state, thread id) in a wide-column or sharded relational store, bodies and attachments in blob storage, content-addressed so one attachment sent to a thousand people is stored once. Search is a per-user inverted index updated asynchronously. Sending goes through an outbound queue to MTAs that sign with DKIM, retry with backoff for days, and handle bounces."

rq-email.mjs
// A Gmail-style mailbox service. Assumptions: 1 billion mailboxes, 40 messages received per day each,
// 50 KB average message (bodies small, attachments dominate), 30% of bytes are attachments shared
// by many recipients (stored once, so counted as ~0 per recipient), 2 copies in two regions.
const users = 1e9, perUser = 40, avg = 50e3, dedupedShare = 0.3, copies = 2;
const msgs = users * perUser;
const bytes = msgs * avg * (1 - dedupedShare) * copies;
console.log(`incoming: ${(msgs / 1e9).toFixed(0)} billion messages/day = ${Math.round(msgs / 86_400).toLocaleString("en-US")}/s avg`);
console.log(`new storage: ${(bytes / 1e15).toFixed(1)} PB/day, ${(bytes * 365 / 1e18).toFixed(2)} EB/year`);
console.log(`per mailbox: ${(perUser * avg * 365 / 1e9).toFixed(2)} GB/year before dedup`);
Result
incoming: 40 billion messages/day = 462,963/s avg
new storage: 2.8 PB/day, 1.02 EB/year
per mailbox: 0.73 GB/year before dedup
What’s happening
  1. Accept, then process: SMTP servers write the message durably before answering 250 OK, because SMTP retries on failure and a lost accepted message is unrecoverable. Spam and malware filtering, threading and labelling happen in a queue-driven pipeline after that.
  2. Storage by mailbox: every read is "this user's messages", so the user id is the partition key; a thread or message id orders within it. Deduplicating attachments by content hash is safe within the service (the client never learns whether someone else has a file, unlike client-side dedup).
  3. Sync: each mailbox has a change log with a monotonically increasing modification sequence; clients ask for "changes since N" (IMAP CONDSTORE, or JMAP, RFC 8620), which is the chat-sync idea again.
  4. Sending at scale: Gmail's sender guidelines (checked October 2026) require senders of more than 5,000 messages a day to Gmail to authenticate with SPF, DKIM and DMARC, keep the spam rate in Postmaster Tools below 0.30%, use TLS, and support one-click unsubscribe. A bulk-sending service separates transactional and marketing traffic onto different IPs and queues so a campaign can't delay password resets.
  5. Search: an inverted index per user (or per partition of users) is small and can be updated a few seconds behind; global search across users is never needed, which makes this much easier than web search.

Learn more: Search with an inverted index, Design a notification system and Design file storage and sync.

Design an ad-click aggregator

Count clicks per ad per minute for advertisers' dashboards and for billing; clicks are money, so counts must be right.

Show answer

Clicks go through a redirect that logs them to Kafka, a stream processor aggregates per ad per minute with exactly-once state, results go to an OLAP store, and a nightly batch over the raw log reconciles the counts used for billing. "At an assumed 10 billion clicks a day that's about 116,000 a second on average and 350,000 at peak. The click endpoint records the event with a click id and redirects; Kafka is partitioned by ad id; Flink keeps per-(ad, minute) counts in checkpointed state and writes them idempotently, keyed by (ad, minute), so a replay overwrites rather than adds. Dashboards read the minute rollups, at most 2.9 billion rows a day. Because billing must be exact, the raw events also land in object storage and a batch job recomputes daily totals, deduplicated by click id, which is what invoices use."

rq-adclicks.mjs
// Ad click aggregation. Assumptions: 10 billion clicks a day, peak 3x, 2M active ads,
// advertisers query per-ad counts per minute; each event 200 bytes.
const clicks = 10e9, peak = 3, ads = 2e6, eventBytes = 200;
const avg = clicks / 86_400;
console.log(`clicks: ${Math.round(avg).toLocaleString("en-US")}/s avg, ${Math.round(avg * peak).toLocaleString("en-US")}/s peak`);
console.log(`raw events: ${(clicks * eventBytes / 1e12).toFixed(0)} TB/day`);
// Pre-aggregated: at most one row per (ad, minute) that had clicks.
const rowsPerDay = Math.min(ads * 1440, clicks);
console.log(`(ad, minute) rows: at most ${(rowsPerDay / 1e9).toFixed(2)} billion/day, ${(clicks / rowsPerDay).toFixed(1)} clicks per row on average`);
// Kafka partitions if one aggregator instance handles 20,000 events/s:
console.log(`aggregator partitions at 20,000 events/s each: ${Math.ceil(avg * peak / 20_000)}`);
Result
clicks: 115,741/s avg, 347,222/s peak
raw events: 2 TB/day
(ad, minute) rows: at most 2.88 billion/day, 3.5 clicks per row on average
aggregator partitions at 20,000 events/s each: 18
What’s happening
  1. Correct counts: dedupe by click id (retries and double clicks), filter fraud and bots before counting, and make the sink idempotent: an upsert of the minute's total, not an increment, so reprocessing after a crash can't double-count.
  2. Event time, not arrival time: bucket by when the click happened, with a watermark and allowed lateness for late events from mobile clients; very late events are corrected by the batch job.
  3. Hot ads: a viral ad sends one key to one partition. Pre-aggregate in the producer or a first stage keyed by (ad, random shard), then combine, the same trick as sharded counters.
  4. Lambda-style reconciliation: the stream gives minute-fresh numbers; the batch over the immutable log gives the authoritative ones. Kappa-style (replay the stream job from the log) is the alternative when the stream job itself is trusted.
  5. Sizing: 2 TB of raw events a day; at an assumed 20,000 events a second per aggregator instance, 18 partitions cover the peak, so plan for more (for growth and rebalancing).

Learn more: Batch vs stream processing, Idempotency and exactly-once and Resharding and hot partitions.

Design top-k: the most played songs in the last hour
Show answer

Count approximately in a stream, keep exact counts only for candidates: a count-min sketch plus a heap of the top k per window, merged across partitions. "With 500 million plays an hour, exact counts for every song in every window are wasteful when only the top 100 matter. Each stream partition keeps a count-min sketch for the current minute and a small heap of its heaviest songs; a merger combines the partitions' candidates every few seconds, and the hourly answer is the sum of the last 60 minute-windows. With ε = 10⁻⁵ the sketch is under 8 MB and over-counts by at most 5,000 plays, while the 100th song has about 100,000, so the ranking is reliable."

rq-topk.mjs
// Top-k "most played songs in the last hour". Count-min sketch sizing:
// width w = ceil(e / epsilon), depth d = ceil(ln(1 / delta)); error <= epsilon x N with probability 1 - delta.
const N = 500e6; // plays in the hour
for (const epsilon of [1e-4, 1e-5]) {
  const w = Math.ceil(Math.E / epsilon), d = Math.ceil(Math.log(1 / 0.001));
  console.log(`epsilon ${epsilon}: ${w.toLocaleString("en-US")} x ${d} counters = ` +
    `${((w * d * 4) / 1e6).toFixed(1)} MB, overcount <= ${(epsilon * N).toLocaleString("en-US")} plays`);
}
// A song in the top 100 usually has far more plays than that error, so its rank is reliable.
console.log(`hourly plays of the 100th song, if it gets 0.02% of plays: ${(N * 0.0002).toLocaleString("en-US")}`);
Result
epsilon 0.0001: 27,183 x 7 counters = 0.8 MB, overcount <= 50,000 plays
epsilon 0.00001: 271,829 x 7 counters = 7.6 MB, overcount <= 5,000 plays
hourly plays of the 100th song, if it gets 0.02% of plays: 100,000
What’s happening
  1. Sketch sizing: width e / ε and depth ln(1 / δ). For ε = 10⁻⁵ and δ = 0.001, that's 271,829 × 7 four-byte counters, 7.6 MB, with an error of at most εN = 5,000 plays.
  2. Heavy hitters are the easy case: the error is absolute, so it's small next to big counts and huge next to small ones, exactly right for top-k and useless for "how many plays did this obscure song get?".
  3. Sliding windows: keep one sketch per minute and add the last 60; dropping the oldest minute is subtracting its sketch, which a decaying single counter can't do.
  4. Partitioning: partition plays by song id, so each song's count lives on exactly one partition. Then each partition's own top k, merged, is the global top k, exactly as the leaderboard's shards each return their top N. If plays were spread randomly, one song's count would be split across partitions and you'd have to sum the sketches before ranking. Two follow-ups a senior interviewer may raise: a viral song puts all its plays on one partition (pre-aggregate "song, +n" at the producers to cut the load), and a song that sits just outside every minute's top k can still belong in the hour's top k, so keep more candidates per minute (say 10k) and re-score them against the summed sketch.
  5. Exact when it matters: for royalties, the batch job over the play log computes exact counts; the stream answer is for the "trending" display.

Learn more: HyperLogLog and count-min sketch and Design search autocomplete.

Design a chat assistant backed by a large language model

Users chat with an AI model; replies stream token by token; conversations are saved.

Show answer

A chat service in front of a pool of model servers: requests stream back over SSE, a scheduler batches requests onto GPUs, conversation history lives in a normal database, and rate limits are in tokens, not requests. "At an assumed 50 million daily users sending 10 messages each, that's about 17,000 replies a second at peak, and because each reply streams for about 10 seconds, about 174,000 open streams at once: that concurrency, not the request rate, sizes the connection tier. The API server authenticates, loads the conversation, builds the prompt and sends it to an inference gateway that queues by model and priority and routes to servers with free capacity, preferably ones that already hold this conversation's prefix in cache. Tokens stream back over server-sent events, the way the major model APIs stream (Anthropic's Messages API documents SSE streaming, checked October 2026). The finished reply is saved with the conversation."

rq-llm.mjs
// An LLM chat assistant. Assumptions: 50M DAU x 10 messages a day, peak 3x, 200-token user messages,
// 2,000 tokens of context sent to the model per turn, 600 output tokens per reply streamed at 60 tokens/s.
const dau = 50e6, msgs = 10, peak = 3, userTokens = 200, contextTokens = 2_000, outTokens = 600, tokPerSec = 60;
const rps = (dau * msgs) / 86_400;
const streamSeconds = outTokens / tokPerSec;
const concurrent = rps * peak * streamSeconds; // Little's law: arrivals x duration
console.log(`replies: ${Math.round(rps).toLocaleString("en-US")}/s avg, ${Math.round(rps * peak).toLocaleString("en-US")}/s peak`);
console.log(`each reply streams for ${streamSeconds} s -> ${Math.round(concurrent).toLocaleString("en-US")} open streams at peak`);
console.log(`tokens at peak: ${(rps * peak * contextTokens / 1e6).toFixed(1)}M/s in (prefill), ${(rps * peak * outTokens / 1e6).toFixed(1)}M/s out (decode)`);
console.log(`stored history: ${(dau * msgs * (userTokens + outTokens) * 4 / 1e12).toFixed(1)} TB/day at ~4 bytes per token`);
Result
replies: 5,787/s avg, 17,361/s peak
each reply streams for 10 s -> 173,611 open streams at peak
tokens at peak: 34.7M/s in (prefill), 10.4M/s out (decode)
stored history: 1.6 TB/day at ~4 bytes per token
What’s happening
  1. Little's law sizes the streaming tier: arrivals × duration = 17,361 × 10 s ≈ 174,000 concurrent streams. Long-lived streaming connections need gateways with idle timeouts above the longest reply, heartbeats, and resumable responses so a dropped connection can fetch the rest by message id.
  2. GPU capacity is the scarce resource: prefill (reading 35 M context tokens a second here) and decode (writing 10 M tokens a second) have different costs, and throughput comes from batching many requests per GPU. Measure tokens per second per GPU for the chosen model; don't guess it.
  3. Prefix caching and routing: sending a conversation's turns to a server that already holds its prefix avoids recomputing the context, which is why routing is sticky-ish by conversation, with fallback when that server is busy.
  4. Admission control: rate-limit by tokens per minute per user and per tier, queue with priorities, and shed or degrade (a smaller model, shorter maximum output) under overload instead of letting queues grow past the client's patience.
  5. The rest is ordinary: conversation history (about 1.6 TB a day here) in a partitioned store keyed by user and conversation, safety filters on input and output, idempotency keys so a retried send doesn't produce two replies, and usage metering for billing.

Learn more: Real-time options: polling, long polling, SSE, WebSockets, Backpressure and load shedding and Rate limiting algorithms.

Design a distributed message queue like Kafka
Show answer

A partitioned, replicated append-only log: producers append to a partition's leader, followers replicate, consumers track offsets, and a controller decides leadership. "Each topic is split into partitions, each partition an ordered sequence of segment files on a broker, replicated to two followers. A write is acknowledged when the in-sync replicas have it (acks=all), so losing the leader loses nothing acknowledged. Consumers in a group each own some partitions and commit offsets, so the broker doesn't track individual messages, which is why it's fast and why replay is free. Partition count caps consumer parallelism, so it's sized for future peak, and retention is budgeted as rate × size × days × replicas."

rq-kafka.mjs
// Sizing a Kafka topic. Assumptions: 100,000 events/s at peak, 1 KB each,
// one consumer instance handles 2,500 events/s, keep 7 days, replication factor 3.
const peak = 100_000, bytes = 1_000, perConsumer = 2_500, days = 7, rf = 3;
const avg = peak / 3; // peak is 3x the average
const minPartitions = Math.ceil(peak / perConsumer);
console.log(`partitions for parallelism: at least ${minPartitions} (plan ${minPartitions * 2} for growth)`);
const disk = avg * bytes * 86_400 * days * rf;
console.log(`disk: ${(disk / 1e12).toFixed(1)} TB for ${days} days x RF ${rf}`);
console.log(`peak ingest: ${(peak * bytes / 1e6).toFixed(0)} MB/s, x${rf} on the wire for replication`);
Result
partitions for parallelism: at least 40 (plan 80 for growth)
disk: 60.5 TB for 7 days x RF 3
peak ingest: 100 MB/s, x3 on the wire for replication
What’s happening
  1. Sizing: 100,000 events a second at peak with consumers that handle 2,500 each needs at least 40 partitions; plan about twice that, because adding partitions later changes which partition a key maps to.
  2. Disk: a third of the peak on average, 1 KB events, 7 days, replication factor 3 is about 60 TB. Long history moves to tiered object storage instead of broker disks.
  3. Speed comes from the log: sequential appends, the OS page cache, batching and zero-copy transfer to consumers. Ordering is per partition, so key by the entity whose order matters.
  4. Durability: replication factor 3, min.insync.replicas = 2 and acks=all means a write survives one broker loss; leader election among in-sync replicas avoids losing acknowledged data. Kafka 4.x runs its own Raft-based controller quorum (KRaft) instead of ZooKeeper.
  5. Delivery: at-least-once by default; idempotent producers remove duplicates between producer and broker, and transactions make read-process-write within Kafka atomic. Effects outside Kafka still need idempotency keys.

Learn more: Queues vs logs, Delivery guarantees and ordering and Replication: leader-follower, multi-leader and leaderless.

Design an online code judge like LeetCode

Users submit code; it's compiled and run against hidden tests in a sandbox; results come back in seconds; contests create huge spikes.

Show answer

Submissions go into a queue, sandboxed workers run them in short-lived microVMs or containers with strict limits, and results come back through polling or a push channel. "The submit endpoint stores the code with an id and returns 202. Workers pull jobs, start a fresh sandbox per submission (a microVM such as Firecracker, which documents boot in under 125 ms), compile, run each test with CPU, memory, time and no-network limits, and write the verdict. At an assumed contest peak of 167 submissions a second, each using about 6 seconds of one vCPU for 30 tests, that's about 1,000 busy vCPUs, 1,500 at a safe utilisation. Contests get their own queue and pre-scaled workers, so practice traffic can't delay them."

rq-judge.mjs
// An online judge during a contest. Assumptions: 50,000 contestants, 1 submission each per 5 minutes
// at the peak, 30 test cases per submission, 0.2 s CPU per test, one sandbox per submission on 1 vCPU,
// plus 0.15 s to start a microVM (Firecracker documents < 125 ms boot).
const contestants = 50_000, every = 300, tests = 30, perTest = 0.2, start = 0.15;
const rate = contestants / every; // submissions per second
const busy = start + tests * perTest; // seconds of one vCPU per submission
const vcpus = rate * busy; // Little's law: in-flight sandboxes
console.log(`submissions: ${rate.toFixed(0)}/s at peak, ${busy.toFixed(2)} s of vCPU each`);
console.log(`busy vCPUs: ${Math.ceil(vcpus).toLocaleString("en-US")} (run at 70%: ${Math.ceil(vcpus / 0.7).toLocaleString("en-US")})`);
// Slow or looping code: 10% of submissions run 5 tests to a 2 s time limit (1.8 s more than usual each).
console.log(`with 10% hitting the 2 s limit on 5 tests: +${Math.round(rate * 0.1 * 5 * (2 - perTest))} busy vCPUs`);
Result
submissions: 167/s at peak, 6.15 s of vCPU each
busy vCPUs: 1,025 (run at 70%: 1,465)
with 10% hitting the 2 s limit on 5 tests: +150 busy vCPUs
What’s happening
  1. Little's law again: 167 submissions a second × 6.15 vCPU-seconds each = 1,025 vCPUs busy. Slow or looping solutions dominate the tail: 10% of submissions running 5 tests to a 2-second limit adds about 150 more, which is why per-test time limits are strict.
  2. Isolation is the security requirement: untrusted code gets no network, a read-only filesystem except a scratch directory, seccomp filters, cgroup limits, and a fresh sandbox per submission. MicroVMs give a stronger boundary than containers at a small cost (Firecracker documents under 5 MiB of overhead per VM and up to 150 microVMs a second per host).
  3. Queues per priority: contest submissions, practice submissions and "run" (custom input) jobs on separate queues with reserved workers, the notification system's lesson about head-of-line blocking.
  4. Results: the client polls GET /submissions/{id} or listens on SSE; verdicts are idempotent writes keyed by submission id, so a worker that dies mid-run just means the job is retried.
  5. Contest leaderboard: a sorted set per contest updated from verdicts, with penalty time packed into the score, which is chapter 11's leaderboard.

Learn more: Why queues, Design a notification system and Design a leaderboard.

Design live comments on a live video stream

Millions of viewers on one stream; thousands of comments a second; viewers see new comments within a couple of seconds.

Show answer

A chat system where one room has millions of readers, so the design is about fan-out: viewers hold a connection to a gateway, comments are written once and published per stream, and each viewer gets a sampled, batched feed rather than every comment. "With an assumed 2 million viewers and 3,000 comments a second, sending everything to everyone is 6 billion messages a second, and nobody can read 3,000 comments a second anyway. So the comment service stores each comment, publishes it to the stream's channel, and a per-stream sampler picks about 10 a second (friends' comments and highly rated ones first) for broadcast. Gateways subscribe once per stream and push one batched frame every 500 ms to each of their viewers: 4 million frames a second across about 20 gateways."

rq-live-comments.mjs
// Live comments on one big stream. Assumptions: 2M concurrent viewers, 3,000 comments/s posted,
// each viewer's screen can usefully show about 10 new comments a second.
const viewers = 2e6, posted = 3_000, shown = 10;
const naive = viewers * posted;
console.log(`send every comment to every viewer: ${(naive / 1e9).toFixed(0)} billion messages/s`);
const sampled = viewers * Math.min(shown, posted);
console.log(`sample to ${shown}/s per viewer: ${(sampled / 1e6).toFixed(0)}M messages/s (${(naive / sampled).toFixed(0)}x less)`);
// Batch them: one frame per viewer every 500 ms carrying ~5 comments.
console.log(`batched every 500 ms: ${(viewers * 2 / 1e6).toFixed(0)}M frames/s`);
console.log(`gateways at 100,000 viewers each: ${viewers / 100_000}`);
Result
send every comment to every viewer: 6 billion messages/s
sample to 10/s per viewer: 20M messages/s (300x less)
batched every 500 ms: 4M frames/s
gateways at 100,000 viewers each: 20
What’s happening
  1. Fan-out cost is viewers × messages: 2 M × 3,000 = 6 billion deliveries a second. Sampling to what a screen can show cuts it 300×, and batching into frames every 500 ms cuts the number of frames to 4 M a second.
  2. Gateways subscribe per stream, not per viewer: each gateway receives the stream's sampled comments once and fans out locally to its 100,000 viewers, so the backplane carries each comment once per gateway: at most 3,000 × 20 = 60,000 messages a second even if gateways take every comment to merge in friends' ones (step 3), not 6 billion.
  3. Personalisation at the edge: a viewer's own comments and their friends' comments are always included, merged in by the gateway, so everyone sees their own message instantly even if the sampler skipped it.
  4. Write path: comments are rate-limited per user, moderated (filters before broadcast, takedown after), and stored with a sequence number per stream so replays and late joiners can fetch recent ones.
  5. Reactions (hearts, likes) are aggregated counts, not individual messages: count per second per stream and broadcast the totals, the same idea as the likes counter.

Learn more: Scaling WebSockets, Design a chat system and Pub/sub and fan-out.

#Frontend system design questions

Added

The frontend round asks the same question as the backend one, "can you design this and defend it?", about a different machine: one user's browser on hardware and a network you don't control. These questions cover the RADIO framework, rendering strategies, state ownership, client caching, long lists, real-time updates, Core Web Vitals, loading, offline, accessibility, security, micro-frontends, and the recurring points from the frontend case studies.

The mental model: the browser is a small, unreliable, single-threaded server that you deploy to millions of strangers. Every byte you send has to cross a slow link, every millisecond of JavaScript blocks the only thread that responds to taps, and every piece of state needs an owner, exactly like data in a backend. Answers that score treat performance, accessibility and security as requirements with numbers, not as polish at the end.

How do you structure a frontend system design answer?
Show answer

RADIO: requirements, architecture, data model, interface, optimisations, with most of the time on the last one. "Requirements include devices, network, SEO and accessibility targets, with numbers: LCP under 2.5 s on a mid-range phone. Architecture is the component tree and the rendering strategy. The data model is the client entities and who owns each piece of state. The interface is the server API the UI needs (pagination, fields, real-time) and the component contracts. Optimisations are performance, loading, offline, accessibility and security, which is where the depth is."

What’s happening
  1. The backend round is about many users on hardware you control; the frontend round is about one user on hardware you don't. Throughput becomes bytes, round trips and main-thread time.
  2. Interface is where the two rounds meet: designing the API the UI needs (cursor pagination, what each list item carries, how live updates arrive) is part of the answer.
  3. Don't turn the round into database sharding, but don't wave the backend away as "an API" either.
  4. Put numbers on optimisations: a byte budget for the critical path, a DOM-size limit for long lists, an INP target.
  5. The usual time split is a few minutes each on R, A, D and I, and the rest on O.

Learn more: The frontend system design round.

Marketing pages, product pages and a logged-in dashboard: how would you render each?
Show answer

Choose per route from three questions: must it be indexed or previewed, is it different per user, and how often does it change? "Marketing and docs: static generation on a CDN. Product pages, the same for everyone but changing: incremental regeneration or SSR behind a short CDN cache with revalidation. Personalised and indexed pages: streamed SSR, with the per-user parts kept out of shared caches. The logged-in dashboard: client-side rendering behind a cached shell. And whatever the strategy, I limit what hydrates, because rendering decides when content appears but JavaScript decides when it responds."

What’s happening
  1. SSG is the fastest first paint (served from the edge) but only for content that's the same for everyone and changes rarely.
  2. ISR / short CDN TTL with stale-while-revalidate keeps SSG's speed for content that changes, with bounded staleness.
  3. SSR suits personalised or fast-changing pages that must be indexed; streaming SSR sends the shell first so one slow query doesn't block the whole page.
  4. CSR is fine behind a login where SEO doesn't matter, but the first view waits for the JavaScript.
  5. Caching SSR output that contains anything per-user (a name, a cart count, a CSRF token) can serve one user's page to another: mark it private or keep personal data out of the cached HTML.

Learn more: Rendering strategies for an app.

Where do the search results, the filters and the theme live?
Show answer

Search results are server state in a query cache, filters are URL state, the theme is client state in a small store. "I classify state by owner. Server data belongs to the server, so the client holds a cache of it keyed by query, with staleness rules. Anything shareable or that Back should restore, like filters, sort and the cursor, belongs in the URL. Session-wide UI preferences live in a small client store, and everything else is local to the component. Each fact has one owner and the rest is derived, so 'filtered items' is computed, not stored."

What’s happening
  1. Copying server data into Redux (fetch in an effect, dispatch into the store) creates a second owner: now you decide staleness, dedupe requests and handle races yourself. Use a query cache (TanStack Query, RTK Query, SWR).
  2. URL state survives reloads, can be shared and bookmarked, and makes Back work; encode it canonically (defaults omitted) so URLs double as cache keys, and validate it, because a URL is user input.
  3. Normalise entities by id in the cache when many views show the same items, so one update reaches every view.
  4. Derived state that's stored drifts out of sync; compute it, and memoise if it's expensive.
  5. Local state (input text, an open menu) stays in the component; lifting it higher than needed causes needless re-renders.

Learn more: State: server state, client state and URL state and Design a data table.

What does stale-while-revalidate look like to the user?
Show answer

They see the cached data instantly, and it quietly updates a moment later if the server has something newer; a spinner appears only the very first time. "The query cache serves fresh data without a request, serves stale data immediately while refetching in the background, and dedupes concurrent requests for the same key. Each kind of data gets a staleness time matching how old it may be. After our own mutations I invalidate the affected keys, or update them optimistically with a rollback."

What’s happening
  1. Within staleTime the cache answers without a network request; after it, the cached value is still shown, and a background refetch replaces it.
  2. Two components asking for the same key at once share one request (dedup).
  3. Invalidation must cancel an in-flight refetch that started before the mutation, or its older response can overwrite the newer data; TanStack Query's invalidateQueries does this by default.
  4. Avoid waterfalls: a parent that fetches, renders, then lets a child fetch makes two sequential round trips (450 ms for three levels on a 150 ms RTT phone). Start a route's queries in parallel at navigation.
  5. The same header exists for HTTP caches: Cache-Control: max-age=60, stale-while-revalidate=300 gives CDNs and browsers the same behaviour.

Learn more: Data fetching and client caching.

A list has 10,000 rows of 56 px in an 800 px viewport. How many rows are in the DOM?

With 5 rows of overscan above and below.

Show answer

About 20 to 25, whatever the list's length: the visible rows plus the overscan. "I'd render a spacer as tall as the whole list (560,000 px here), position only the rows in the window at their offsets, and recompute the window on scroll. Variable heights need measured or estimated heights and a binary search over their running offsets. The costs are find-in-page, focus and scroll restoration, which I'd handle explicitly."

rq-window.mjs
// Windowing maths for a virtualised list: 10,000 rows of 56 px, an 800 px viewport, 5 rows of overscan.
const rows = 10_000, rowHeight = 56, viewport = 800, overscan = 5;
function windowAt(scrollTop) {
  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const last = Math.min(rows - 1, Math.ceil((scrollTop + viewport) / rowHeight) - 1 + overscan);
  return { first, last, rendered: last - first + 1, offset: first * rowHeight };
}
console.log(`spacer height: ${(rows * rowHeight).toLocaleString("en-US")} px`);
for (const top of [0, 28_000, rows * rowHeight - viewport]) {
  const w = windowAt(top);
  console.log(`scrollTop ${String(top).padStart(6)}: rows ${w.first}-${w.last} (${w.rendered} in the DOM), first at ${w.offset} px`);
}
Result
spacer height: 560,000 px
scrollTop      0: rows 0-19 (20 in the DOM), first at 0 px
scrollTop  28000: rows 495-519 (25 in the DOM), first at 27720 px
scrollTop 559200: rows 9980-9999 (20 in the DOM), first at 558880 px
What’s happening
  1. The first rendered row is floor(scrollTop / rowHeight) − overscan, clamped at 0; the last is ceil((scrollTop + viewport) / rowHeight) − 1 + overscan, clamped at the end.
  2. At the top: rows 0–19, 20 elements (15 visible, 5 below, none above). Mid-list at 28,000 px: rows 495–519, 25 elements. At the bottom: 20 again.
  3. The rendered block is offset by first × rowHeight (27,720 px mid-list) inside the full-height spacer, so the scrollbar behaves as if all 10,000 rows existed.
  4. Off-screen rows don't exist, so Ctrl+F can't find them, focus is lost when a focused row scrolls away, and screen readers need aria-setsize and aria-posinset. Save scroll position as the first visible item's id plus offset.
  5. For a few hundred rows, skip virtualisation and use content-visibility: auto; for pages of 50–100 table rows, it's often not worth the costs.

Learn more: Infinite scroll and list virtualisation and Design a news feed UI.

Infinite scroll for a feed: what goes wrong, and what do you build?
Show answer

Cursor pagination, a prefetch threshold, dedup by id, a new-posts banner instead of inserting at the top, and virtualisation once the list gets long. "I fetch the next page when the last rendered item is within a few cards of the end, with the in-flight request deduplicated and an explicit end state. Pages come by cursor, because new posts at the top would make offsets repeat posts, and the store still dedups by id because ranking can repeat a post. New posts show as a banner that inserts them on tap, so content never jumps under the reader."

What’s happening
  1. Offset pagination in a live feed repeats or skips posts as new ones are published; a cursor asks for "after this one".
  2. Fetching a few cards early hides the network: chapter 14's estimate gave about 1.7 s of head start even on a fast fling with a five-card threshold.
  3. Inserting new posts above the reader moves what they're reading (a CLS problem and a usability one); a banner with a count is the standard pattern.
  4. Optimistic likes: update the count immediately, send an idempotent PUT {liked}, roll back only the failed operation, and let the server's count replace the guess.
  5. After a long session, windowing keeps the DOM near a few hundred nodes instead of tens of thousands.

Learn more: Design a news feed UI and Pagination at the API level.

Why debounce an autocomplete, and what else does it need?

The user types "system design" with natural pauses.

Show answer

Debouncing sends a request only when the user pauses, which turns 13 keystrokes into about 3 requests; it also needs latest-request-wins and a cache. "I'd debounce at about 250 ms, because an average typist presses a key about every 230 ms, so requests fire on pauses. Responses can arrive out of order, so each request gets a ticket and only the latest one is shown, with an AbortController to cancel the old ones. A per-query cache makes backspacing free, and the endpoint is a cacheable GET /suggest?q= so a CDN serves popular prefixes."

rq-debounce.mjs
// How many requests does typing "system design" send? Gaps between keystrokes in ms (a fake clock),
// with a 600 ms pause after "system" and a 400 ms pause after "sys".
const text = "system design";
const gaps = [0, 210, 190, 400, 220, 180, 600, 230, 200, 240, 210, 190, 230];
function requests(debounceMs) {
  const sent = [];
  let t = 0, timer = null;
  for (let i = 0; i < text.length; i++) {
    t += gaps[i];
    if (timer && timer.at <= t) sent.push(timer.q); // the pending call fired before this key
    timer = { at: t + debounceMs, q: text.slice(0, i + 1) };
  }
  sent.push(timer.q); // the last one fires after typing stops
  return sent;
}
console.log(`no debounce: ${text.length} requests`);
for (const ms of [150, 250, 500]) {
  const r = requests(ms);
  const list = r.length <= 4 ? ` -> ${r.map((q) => JSON.stringify(q)).join(", ")}` : " (one per key)";
  console.log(`debounce ${ms} ms: ${r.length} requests${list}`);
}
Result
no debounce: 13 requests
debounce 150 ms: 13 requests (one per key)
debounce 250 ms: 3 requests -> "sys", "system", "system design"
debounce 500 ms: 2 requests -> "system", "system design"
What’s happening
  1. Without debouncing, every keystroke is a request: 13 for "system design".
  2. With 150 ms, every gap in this typing (180–600 ms) is longer than the debounce, so nothing is saved: the debounce must be longer than the usual gap between keys.
  3. With 250 ms, requests fire only after the pauses: "sys", "system" and the final "system design". That's 3 instead of 13, about 77% fewer.
  4. With 500 ms only "system" and the final query fire: fewer requests, but suggestions feel sluggish. The value is a trade-off between load and responsiveness.
  5. The rest of the component: APG combobox behaviour (focus stays in the input, aria-activedescendant, arrows, Enter, Escape), no fetch during IME composition, and highlighting matches with text nodes, never innerHTML.

Learn more: Design an autocomplete component.

What are the Core Web Vitals targets, and what moves each one?
Show answer

LCP at most 2.5 s, INP at most 200 ms, CLS at most 0.1, each judged at the 75th percentile of real visits, per device type. "LCP moves with the critical path: server-render the first view, serve from a CDN, put the hero image in the HTML with fetchpriority=high at the right size. INP moves with main-thread work: small handlers, yielding in long tasks, virtualised lists, fewer third-party scripts. CLS moves with reserved space: dimensions on media, reserved ad slots, banners that overlay instead of pushing content."

What’s happening
  1. LCP is when the largest image or text block in the viewport is painted; INP is the delay from a tap or key press to the next frame, across the whole visit; CLS is how much visible content moves unexpectedly (web.dev, checked October 2026).
  2. p75 means a page passes when three out of four real visits are good, so the slow tail (old phones, poor networks) decides.
  3. Lighthouse is a lab score from one simulated device that doesn't tap anything; Core Web Vitals are field data. Use the lab to debug and to guard budgets in CI, field data (CrUX, RUM) to decide you're done.
  4. Turn the LCP goal into a byte budget: on chapter 13's slow-phone model, six round trips at 150 ms leave 1.6 s of transfer at 1.6 Mbit/s, about 312 KB for everything on the critical path.
  5. INP replaced First Input Delay on 12 March 2024; FID only measured the delay before the first handler started.

Learn more: Web performance budgets and Core Web Vitals.

Which image file does a 360 px slot on a 3× phone download?

srcset offers 480, 768, 1080, 1440 and 1920 px wide files, and sizes says the slot is 360 px.

Show answer

The 1080 px file: the browser needs slot width × device pixel ratio, 360 × 3 = 1,080 device pixels. "That's why sizes must be accurate: without it the browser assumes the image fills the viewport and may pick a far bigger file. Capping at 2× would load the 768 px file instead, roughly half the bytes, which is usually invisible on a phone. Images come from an image CDN in AVIF or WebP, lazy below the fold, and the LCP image is eager with high fetch priority."

rq-srcset.mjs
// Which srcset candidate does a browser need? Slot width (CSS px, from `sizes`) x device pixel ratio,
// rounded up to the next candidate.
const candidates = [480, 768, 1080, 1440, 1920];
const pick = (slotCss, dpr) => candidates.find((w) => w >= slotCss * dpr) ?? candidates.at(-1);
for (const [device, slot, dpr] of [["phone", 360, 3], ["phone, capped at 2x", 360, 2], ["tablet", 768, 2], ["laptop", 600, 1]]) {
  console.log(`${device.padEnd(20)} ${slot} px slot x ${dpr} = ${slot * dpr} px -> loads the ${pick(slot, dpr)}w file`);
}
Result
phone                360 px slot x 3 = 1080 px -> loads the 1080w file
phone, capped at 2x  360 px slot x 2 = 720 px -> loads the 768w file
tablet               768 px slot x 2 = 1536 px -> loads the 1920w file
laptop               600 px slot x 1 = 600 px -> loads the 768w file
What’s happening
  1. The browser computes the slot width from sizes (for example (max-width: 600px) 100vw, 600px), multiplies by the device pixel ratio and picks the smallest candidate at least that wide (browsers may choose differently under data-saving or caching, so it's a hint).
  2. 360 × 3 = 1,080, exactly a candidate; at 2× it's 720, so the 768 px file.
  3. A 768 px slot on a 2× tablet needs 1,536 px and gets the 1,920 px file; a 600 px slot on a 1× laptop gets 768.
  4. loading="lazy" on the hero, a hero in a CSS background, or a hero injected by JavaScript all delay LCP; keep it in the HTML.
  5. Reserve each image's aspect ratio (width and height attributes or aspect-ratio) so CLS stays at zero while images load.

Learn more: Loading strategy: code splitting, prefetching and images and Design an image carousel and gallery.

Your WebSocket server restarts and 10,000 clients reconnect. Why add jitter?
Show answer

Because without it every client retries at the same moment and the reconnects arrive as one spike; full jitter spreads them over the whole backoff window. "Clients reconnect with exponential backoff, capped at about 30 seconds, and a random delay between zero and the current cap. They reset the backoff only after a connection has stayed up for a while, resume from their last sequence number, and do a full refetch if they're too far behind."

rq-backoff.mjs
// 10,000 clients lose their WebSocket at t = 0 and reconnect after a delay. How many arrive in the
// busiest 100 ms? Full jitter: delay = random(0, min(cap, base * 2^attempt)). Seeded PRNG (mulberry32).
function mulberry32(seed) {
  return () => {
    seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const clients = 10_000, base = 1000, cap = 30_000;
const busiest = (delays) => {
  const buckets = new Map();
  for (const d of delays) buckets.set(Math.floor(d / 100), (buckets.get(Math.floor(d / 100)) ?? 0) + 1);
  return Math.max(...buckets.values());
};
const fixed = Array.from({ length: clients }, () => base); // everyone waits exactly 1 s
const rand = mulberry32(42);
const jittered = Array.from({ length: clients }, () => rand() * Math.min(cap, base * 2 ** 3)); // 4th attempt: up to 8 s
console.log(`fixed 1 s delay:     busiest 100 ms gets ${busiest(fixed).toLocaleString("en-US")} reconnects`);
console.log(`full jitter, 0-8 s:  busiest 100 ms gets ${busiest(jittered)} reconnects`);
console.log(`caps per attempt: ${[0, 1, 2, 3, 4, 5, 6].map((a) => Math.min(cap, base * 2 ** a) / 1000 + " s").join(", ")}`);
Result
fixed 1 s delay:     busiest 100 ms gets 10,000 reconnects
full jitter, 0-8 s:  busiest 100 ms gets 158 reconnects
caps per attempt: 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, 30 s
What’s happening
  1. With a fixed 1-second delay, all 10,000 reconnects land in the same 100 ms: the restarted server, or its neighbours, take the whole storm at once.
  2. With full jitter at the 4th attempt (a random delay between 0 and 8 s), the busiest 100 ms gets 158 reconnects in this seeded run: about 60 times flatter.
  3. The caps double per attempt, 1, 2, 4, 8, 16 s, then stay at 30 s, so a long outage doesn't make clients wait minutes once the server is back.
  4. Resetting the backoff only after a stable connection stops a flapping server from getting instant retries forever.
  5. Events carry sequence numbers, so after reconnecting the client asks for everything after its last one, drops duplicates and detects gaps.

Learn more: Real-time updates in the client and Retries, dead-letter queues and poison messages.

A chat app's messages appear in different orders on different devices. Why, and what's the fix?
Show answer

They're sorted by each sender's device clock; order must come from the server's per-conversation sequence number. "Device clocks drift and are set by hand, so createdAt can put a reply above its question. The server assigns a gap-free seq per thread when it stores the message and acks with it. The client shows confirmed messages by seq, pending ones after them, never reorders a confirmed message, and because seqs have no gaps it can detect missing messages and fetch the exact range."

What’s happening
  1. Sending is optimistic: the message gets a clientId and appears at once as pending.
  2. The server's broadcast of that message can arrive before its ack, so both paths upsert by clientId: one message on screen, never two.
  3. Offline, messages wait in an outbox in IndexedDB and are flushed in order on reconnect; the server dedups resends by (sender, clientId).
  4. After a reconnect, the client asks for everything after its last contiguous seq; a live message that arrives before the gap is filled is held until it is.
  5. The list is a bottom-anchored virtualised log that only auto-scrolls when you're already at the bottom.

Learn more: Design a chat app UI and Design a chat system.

How would you make the app work offline?
Show answer

A service worker with a strategy per request type, plus an outbox for writes. "The app shell and versioned assets are precached and served cache-first, which is safe because content-hashed URLs never change. Navigations are network-first with a short timeout, falling back to the cached page. Personal API data stays network-only or goes into IndexedDB that's cleared on logout. Offline writes go into an IndexedDB outbox with idempotency keys and replay on reconnect, with Background Sync where it exists, which is only Chromium."

What’s happening
  1. Cache-first for immutable assets, network-first with timeout for pages, stale-while-revalidate for content that can be slightly old, network-only for personal or sensitive data.
  2. A slow network should behave like offline, not like a white screen: that's what the timeout on network-first is for.
  3. Browser storage is best-effort: Safari deletes script-writable storage for a site after seven days of use without visiting it, so ask for navigator.storage.persist() and never keep the only copy of user work in the browser.
  4. Cached API responses outlive a logout unless you delete them; clear them on logout.
  5. Show pending and offline states clearly, so a user knows which of their changes haven't reached the server.

Learn more: Offline support and service workers.

Where do you keep the access token in a single-page app?
Show answer

Ideally not in JavaScript at all: a backend for frontend holds the OAuth tokens and the browser gets an HttpOnly, Secure, SameSite session cookie. "Anything JavaScript can read, an XSS bug can steal, so tokens in localStorage are one injection away from leaking. With a session cookie, CSRF becomes the threat instead, so state-changing requests check the Origin header or a CSRF token, and nothing changes state on GET. Against XSS itself: the framework's escaping, no raw-HTML sinks without DOMPurify, a strict nonce-based CSP and Trusted Types."

What’s happening
  1. HttpOnly stops JavaScript reading the cookie; Secure keeps it on HTTPS; SameSite=Lax stops it being sent on most cross-site requests.
  2. A BFF exchanges the OAuth code server-side and keeps refresh tokens away from the browser entirely.
  3. If a token must live in the browser, keep it in memory, short-lived, with refresh through an HttpOnly cookie.
  4. React escapes text and blocks javascript: URLs in React 19, but nothing protects dangerouslySetInnerHTML, direct DOM writes or user URLs passed to window.location.
  5. frame-ancestors in CSP stops clickjacking; SRI on third-party scripts stops a compromised CDN file from running.

Learn more: Frontend security.

OT or CRDT for a collaborative editor?
Show answer

OT when there's always a central server and you want compact operations; a CRDT such as Yjs when you need offline editing or peer-to-peer and can afford the metadata. "Both solve convergence under concurrent edits, where 'insert at 3' means something different once someone else has inserted before 3. With OT the server defines one order and transforms late operations against everything since their base revision. With a CRDT each character has a unique id and an anchor, deletes are tombstones, and replicas converge in any delivery order."

What’s happening
  1. OT: each client keeps one batch in flight and transforms incoming operations against its pending ones; the server transforms against history, with a deterministic tie-break such as site id.
  2. CRDT: no central order is needed, so offline edits merge on reconnect; the cost is per-character ids and tombstones, which grow and need compaction.
  3. Either way: an append-only op log with periodic snapshots, broadcasts batched per tick (about 20 a second per client instead of one per keystroke), cursors anchored to items rather than offsets.
  4. Presence (who's here, cursors) is ephemeral and throttled; it never goes in the document log.
  5. Use an editor framework for contenteditable, selection and IME handling: applying remote edits during an IME composition corrupts it.

Learn more: Design a collaborative document editor and Conflict resolution: last-write-wins and CRDTs.

How do you stop a double click from placing two orders?
Show answer

One idempotency key per checkout attempt, sent with the place-order request and reused on every retry; the server recomputes every amount and rejects a changed cart version. "Disabling the button helps the common case, but timeouts, retries and a second tab still produce duplicates, so the server is what guarantees it: it claims the key atomically and returns the first result for any repeat. The browser never decides prices or totals, and the payment provider's webhook, not the browser, marks the order paid."

What’s happening
  1. The key is generated when the checkout page loads (or on first click), not per HTTP attempt.
  2. Any total sent by the client can be edited in DevTools, so the order API recomputes from SKUs, quantities, shipping option and coupon, in integer minor units.
  3. A stale cartVersion is rejected, so a cart changed in another tab can't be ordered at the old price.
  4. Card fields live in the provider's iframe, so card data never touches your servers, which keeps PCI scope small.
  5. Payment success arrives by webhook because the browser can close, crash or lie before it reports back.

Learn more: Design an e-commerce product page and checkout and Idempotency keys for APIs.

When are micro-frontends worth it?
Show answer

Only when the problem is team independence: several teams blocked on each other's releases, or parts of the product with very different lifecycles. "Then I'd split by route first, separate apps behind one domain, and use runtime composition such as Module Federation only where parts must share a page. I'd budget for the costs: a shell owned by a platform team, a design system, agreed shared dependency versions so the framework loads once, explicit contracts, and an error boundary and timeout around each remote. For one or two teams, a monorepo with enforced module boundaries gives most of the benefit without the runtime cost."

What’s happening
  1. Micro-frontends don't remove coupling, they move it: the header, auth state, cart count and URL are still shared, now through runtime contracts no compiler checks.
  2. Route-level splits are the cheapest: each team owns whole pages, and navigation between them is just a link.
  3. Shared-page composition needs version agreements, or users download two copies of the framework.
  4. Each remote gets an error boundary and a timeout, so one team's bad deploy degrades one section instead of the page.
  5. A monorepo with lint-enforced boundaries and affected-only builds solves the organisational problem for small teams without changing the runtime.

Learn more: Micro-frontends and monorepos.

How do you build accessibility into a large app instead of fixing screens one by one?
Show answer

In the platform: accessible primitives in the design system, app-level behaviour in the shell, and automated checks in CI. "The design system ships components that implement the APG patterns (keyboard, focus, ARIA) and tokens that pass WCAG 2.2 AA contrast, checked in its build. The app shell handles what no component can: on every client-side navigation it updates the title and moves focus to the new page's heading. Teams get lint rules and axe in end-to-end tests, plus manual screen-reader checks before release."

What’s happening
  1. Most accessibility bugs come from custom controls; a design system that gets the combobox, dialog and tabs right once fixes them everywhere.
  2. Single-page apps change content silently: screen readers announce nothing and focus stays on a link that no longer exists, unless the router moves focus and updates the title.
  3. Automated tools catch only part of the problems, the mechanical ones (missing names, contrast, invalid roles); keyboard and screen-reader testing catch the rest, such as a focus order that makes no sense.
  4. Token changes are releases: a slightly different blue can push a text pair below 4.5:1 in one theme, so tokens go through the same contrast checks and visual regression as code.
  5. Internationalisation belongs in the platform too: ICU messages per locale, Intl for numbers and dates, logical CSS properties for right-to-left.

Learn more: Accessibility and internationalisation at scale and Design a design system and component library.

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.