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

Chapters

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

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

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

Load balancing in depth

The box everyone draws and nobody explains — where L7 earns its latency, why consistent hashing keeps recurring, and how a health check turns a brownout into an outage.

The most-drawn, least-understood box on the whiteboard

Every candidate draws a load balancer. Very few can say what layer it operates at, what it can and cannot see, how it decides where a request goes, how it learns a backend is dead, or what happens to the connections in flight when you deploy. Those five questions are the entire chapter, and they are asked because the load balancer is simultaneously the thing that makes a system horizontally scalable and the single component every request must survive. Get it wrong and your redundancy is decorative — a health check that is too eager can convert a slow backend into a dead fleet in under a minute.

L4 L7 transport: IP and port application: method, path, headers client flow map client TLS + HTTP parse /api /img /ws forwards flows, never reads the body millions of conn/sec, direct server return possible cannot retry, cannot route by path terminates TLS, parses every request 10-50k rps per core, adds 0.5-2 ms retries, path routing, header injection on HTTP/2 only L7 balances individual streams — L4 pins them all to one backend
L4 moves packets and is nearly free; L7 understands requests and charges you a millisecond for it. The bottom line is the one that decides most modern designs.

L4 vs L7: what only L7 can do

An L4 balancer picks a backend once, per connection, from the 5-tuple, then shovels bytes. It never decrypts, never parses, and therefore costs almost nothing — a software L4 on commodity hardware handles millions of concurrent flows and tens of gigabits, and with direct server return the response never traverses the balancer at all, which matters enormously for video and download workloads. An L7 proxy terminates the connection, does the TLS handshake, parses the HTTP request, and makes a fresh decision per request.

CapabilityL4L7Why it matters
Route by host / path / headerNoYesOne public IP fronting twenty services; canary by header; API versioning at the edge
Retry a failed request elsewhereNo — it can only reset the connectionYesL4 doesn't know where a request begins or ends, so it cannot replay one. This is the biggest practical gap
Per-request balancing on HTTP/2 or gRPCNoYesThe one that bites people. gRPC multiplexes many calls over one long-lived connection; an L4 pins every one of them to whichever backend it picked at connect time, and your "balanced" fleet develops permanent hotspots
TLS termination, mTLS, cert managementPassthrough onlyYesCentralised certificates; backends speak plaintext or mesh mTLS
Inject X-Forwarded-For, trace idsNoYesWithout it, every backend log shows the balancer's IP and distributed tracing has no root span
Rate limiting, WAF, response caching, compressionNoYesPer-route policy in one place
Cost and latencyMicroseconds; near-zero CPU0.5-2 ms; a full RSA-2048 handshake is 1-2 ms of CPU, ECDSA far less, and session resumption removes most of itAt a million rps the L7 fleet is a real line item
Reach for this when…Raw throughput, non-HTTP protocols, huge egress, or as the first tier absorbing volume before L7Anything HTTP where you need routing, retries, observability or per-request fairness — which is nearly every application tierReal systems use both, in that order

Algorithms, and when each one is right

AlgorithmHow it decidesFails whenReach for this when…
Round robinNext backend in sequenceRequests vary in cost, or backends vary in size — a slow request pins a server while the rotation keeps feeding itHomogeneous backends and roughly uniform request cost. Still the correct default for a stateless web tier
Weighted round robinProportional to a static weightWeights are guesses and go stale after a hardware refreshMixed instance sizes, or ramping a canary from 1% to 100%
Least connectionsFewest in-flight connectionsEach balancer only sees its own connections; with many balancers they all pick the same "idle" backend at once and stampede itLong-lived or highly variable requests: WebSockets, streaming, uploads, slow queries
Least request / peak-EWMAFewest outstanding requests, weighted by observed latencyNeeds per-backend latency state; reacts to noise if the window is too shortService-to-service traffic behind a mesh, where a degraded backend must be shed automatically
Power of two choicesPick two backends at random, send to the less loaded of the twoAlmost nothing — this is the quiet best-in-class defaultAny fleet with multiple independent balancers. It needs no global state and drops the expected maximum load from roughly log n to log log n
Consistent hashingHash a key onto a ring; take the first node clockwiseHot keys — one popular key means one hot backend, permanentlyCache tiers, sharded stateful services, session affinity without cookies. See below
Source-IP hashHash the client IPCarrier-grade NAT — an entire mobile network arrives as one IP and lands on one backendRarely. Use a cookie or a real key instead

Power of two choices is worth naming explicitly because it sounds like a compromise and is actually close to optimal. Pure random assignment leaves the busiest server with roughly log n / log log n times the average queue; sampling just two and taking the shorter queue reduces that to about log log n — an exponential improvement bought with zero coordination. It is why modern proxies default to it rather than to true least-connections, and mentioning it is a cheap, genuine seniority signal.

⚠ A brand-new backend is not ready for its full share Round robin gives an instance that booted four seconds ago exactly the same traffic as one that has been warm for a week — into a cold cache, an empty connection pool and un-JITed code. It gets slow, fails a health check, drops out, comes back, and oscillates. Every serious balancer has a slow-start or warm-up setting that ramps a new backend's weight from near zero to full over 30-120 seconds. It is one config line and it is the difference between a deploy nobody notices and a deploy that pages someone.

Consistent hashing, properly

Suppose you route cache keys with hash(key) mod N. With four nodes you add a fifth, and every key whose home changes loses its cached value. How many change? Work it out with the Chinese remainder theorem: a key keeps its home only when k mod 4 == k mod 5, which for k mod 20 happens exactly for 0, 1, 2 and 3. That is 4 out of 20 — 20% stay, 80% move. You added capacity and instantly invalidated four fifths of your warm cache.

node A node B node C node D, new k1 k2 k3 a key belongs to the first node clockwise around the ring D takes only the green arc, so k1 moves from B to D k2 and k3 never notice mod-N, 4 → 5 nodes: 80% of keys move ring, 4 → 5 nodes: 20% of keys move virtual nodes: 100-200 ring points per machine, or the arcs come out badly uneven by luck alone adding a node disturbs one arc, not the whole mapping
The property that matters is not that the hash is clever — it is that node membership changes affect an arc rather than a modulus, so the disruption is 1/(N+1) instead of nearly everything.

On a ring, both keys and nodes are hashed into the same space (say 0 to 2³²−1). A key is owned by the first node clockwise from it. Adding a node inserts one new point and it steals only the arc between itself and its predecessor: an expected 1/(N+1) of all keys. Going from four nodes to five moves 20% instead of 80%. At a hundred nodes, adding one moves 1% instead of essentially everything.

Tie that back to the cache arithmetic, because that is where the difference becomes an incident. A tier at a 95% hit rate, resizing from four nodes to five:

  • mod-N: new miss rate = 0.80 + 0.20 × 0.05 = 0.81. Origin load jumps 16×, while you were adding capacity.
  • Consistent hashing: new miss rate = 0.20 + 0.80 × 0.05 = 0.24. A 4.8× bump — still real, still worth ramping the new node in gradually, but survivable.

Two refinements you should mention unprompted. Virtual nodes: placing each machine at a single ring point gives arc lengths drawn from an exponential distribution, so with ten nodes one of them can easily own three times its fair share. Give every machine 100-200 ring points instead and the imbalance shrinks roughly as 1/√V — around ±10% at V = 100 — and you get weighting for free, since a machine with twice the capacity simply gets twice the points. Bounded-load consistent hashing: cap any node at (1 + ε) times the average load and spill the overflow to the next node clockwise, which fixes the one genuine weakness of the ring, namely that a single very hot key concentrates on a single node forever.

Say it like this → "I'd shard the cache with consistent hashing and about 150 virtual nodes per machine. The reason isn't elegance — with plain modulo, replacing one failed node out of five remaps roughly 80% of keys, which takes the hit rate from 95% to about 19% and sends 16× the normal load at the database at the exact moment I'm already degraded. The ring keeps that blast radius to one node's share."

Health checks: active, passive, and how they amplify an outage

Active probingPassive (outlier detection)
MechanismThe balancer calls /healthz every N secondsThe balancer watches real responses and ejects after k consecutive 5xx or timeouts
Detection timeinterval × unhealthy-threshold, plus the timeout — 5 s × 3 is up to ~20 s of served errorsEffectively immediate — it fails on real traffic
Blind spotA backend that returns 200 on /healthz while failing every real request (bad deploy, poisoned cache, exhausted pool)Cannot tell you when a backend has recovered — nothing is being sent to it
RiskProbe traffic at scale, and coupling to dependenciesEjecting a backend for errors the client actually caused
Reach for this when…Always, as the mechanism for re-admitting a recovered instance and for gating new onesAlways, alongside it, as the mechanism for fast removal. Use both; they cover each other's blind spots
⚠ How a health check turns a brownout into an outage Ten backends running at 70% CPU. Two get slow under a traffic bump and fail their probes, so the balancer ejects them. The remaining eight now carry 10/8 = 1.25× the load — 87.5% CPU. Two more go slow and get ejected. The last six carry 10/6 = 1.67× — 117% of capacity — and the fleet is gone. The health check did precisely what it was told: it removed capacity from a system whose problem was insufficient capacity. Real balancers guard this with a panic threshold (Envoy's default: if fewer than 50% of hosts are healthy, ignore health status entirely and spread load across all of them, on the theory that a struggling backend beats no backend). The other half of the fix belongs to the backend: enforce a concurrency limit and shed excess load with a fast 503 rather than degrading into timeouts, so an overloaded server stays honest instead of looking dead.

Two more health-check details that separate operators from readers. First, probe the instance, not the world: a readiness endpoint that checks the database will fail on every instance simultaneously during a 20-second database blip and drain your entire pool — the outage is then total rather than partial. Second, probing costs something: 200 balancer instances each probing 500 backends every 2 seconds is 50,000 probes/sec of pure overhead. Past a certain fleet size you stop having every proxy probe every backend and move to a control plane that distributes health state (xDS-style push), which is also what makes ejection decisions consistent across the fleet instead of each proxy holding its own private opinion.

Connection draining, and the deploy that drops requests

Removing a backend is not an event, it is a sequence, and skipping a step shows up as a small burst of 502s on every single deploy that nobody ever gets around to fixing.

  • 1. Stop being advertised. Fail the readiness probe or deregister from the pool. New requests stop arriving — eventually.
  • 2. Wait for that to propagate. This is the step everyone omits. The balancer may not notice for a full health-check interval, and in Kubernetes the SIGTERM and the endpoint removal happen concurrently, so a container that exits promptly on SIGTERM will drop requests that were routed a moment earlier. A preStop sleep of 5-10 seconds before you begin shutting down is the standard fix.
  • 3. Drain in-flight work up to a deadline — 30 s for a web tier, longer for uploads. Reject anything new with a clean 503.
  • 4. Close keep-alive connections deliberately. Send Connection: close on the last response (or an HTTP/2 GOAWAY), otherwise a client holds an idle socket to a process that is about to vanish and its next request fails.
  • 5. Then exit.

Long-lived connections don't drain, they get evicted. Rolling a fleet holding a million WebSockets disconnects a million clients, all of which reconnect immediately and in unison unless the client backs off with jitter — a self-inflicted denial of service that arrives roughly one second after a successful deploy. Roll in small batches, and make jittered reconnect a client requirement, not a hope.

Sticky sessions and why they are a smell

Affinity — by source IP, by a balancer-issued cookie, or by hashing a header — pins a user to one backend. It works, which is the problem: it lets you keep server-side state that you should have externalised, and it converts a stateless tier into a stateful one without anybody deciding to.

  • Load stops being balanced. Existing sessions never move, so scaling out during a spike adds instances that receive nothing until sessions churn. Autoscaling is least effective exactly when you need it most.
  • Every deploy is a data-loss event. Restarting a backend destroys the sessions, carts and in-memory work of whoever was pinned to it.
  • Draining becomes user-visible. You cannot remove a node gracefully when removal means logging its users out.
  • Failure stops being graceful. One dead instance out of twenty means 5% of users are fully broken, rather than everyone losing one request that gets retried.
  • Source-IP affinity in particular is broken by design. Carrier-grade NAT puts an entire mobile network behind a handful of IPs, and those hash to a handful of backends.

The legitimate uses are narrow and worth naming, so you don't sound dogmatic: a WebSocket or gRPC stream is inherently pinned for its lifetime; a multipart upload buffered on local disk has to finish where it started. And for cache locality — routing the same user to the instance that already has their data warm — the right tool is consistent hashing on the user id, not session affinity, because a ring degrades gracefully when a node dies and rebalances when you add one, whereas affinity does neither. Everything else is solved by putting the session in Redis or in a short-lived signed token, at which point any instance can serve any request and the whole category of problem disappears.

The distinction to keep Sticky sessions are affinity you depend on for correctness. Consistent hashing is affinity you exploit for performance. The first breaks when a node dies; the second just gets slower for a moment. Same routing trick, opposite blast radius.

Multi-tier balancing: DNS, global, regional

At scale there is no "the load balancer" — there are four tiers, each choosing at a different granularity and failing over on a different timescale.

client DNS / anycast — choose a region seconds to minutes to fail over region A region B L4 / ECMP L7 proxy tier pod pod pod identical stack withdrawn from anycast when the region is unhealthy each tier's failover is faster and finer-grained than the tier above it
Granularity increases downwards: DNS moves whole regions in minutes, L4 moves flows in seconds, L7 moves individual requests instantly. Match the failure you're protecting against to the tier that can actually respond in time.
TierChoosesFailover speedGotcha
DNS / GeoDNSWhich region's IP the client getsMinutes, and not really yours to controlTTL is advisory. Resolvers, OSes and browsers all cache; assume 5-15 minutes of residual traffic to a withdrawn record no matter what TTL you set
AnycastWhich POP the packets reach, via BGPSecondsImmune to DNS caching, which is why it's the preferred top tier — but a BGP reconvergence can move a flow mid-connection and reset TCP
Regional L4Which L7 proxy gets the flowSeconds, per flowUse Maglev-style consistent hashing so scaling the L4 tier doesn't reshuffle every existing connection
Regional L7Which backend gets each requestImmediate, per requestThe only tier that can retry. Also the only tier that can be zone-aware
Client-side / mesh sidecarWhich instance, from the caller's own processImmediateRemoves a network hop entirely, but every client now needs service discovery and a control plane to push endpoints

One practical detail from the bottom tier that interviewers like: zone-aware routing. Cross-AZ traffic costs real money (roughly one to two cents per GB, charged in both directions) and adds a millisecond or two. Configuring the L7 tier to prefer backends in its own availability zone, and only spill across zones when the local ones are unhealthy or saturated, cuts both. Say it as a cost decision, because it is one, and cost awareness is rare enough in system design interviews to be memorable.

Recognizing it in an unseen problem

  • The moment a design has more than one instance of anything, the interviewer is entitled to ask how traffic reaches them. Have a default ready: L4 in front of L7, round robin or power-of-two at the bottom, active plus passive health checks, slow start on new instances.
  • Any mention of gRPC, HTTP/2 or long-lived connections between services should make you say "per-request balancing" out loud — an L4 balancer in front of gRPC produces permanent, invisible hotspots and this is a favourite gotcha.
  • A naive design draws one load balancer box, never says which layer, and leaves it as an unreplicated single point of failure in front of a carefully replicated everything-else.
  • Distinguish this from sharding: load balancing spreads stateless work across interchangeable workers; sharding partitions state across non-interchangeable owners. Consistent hashing shows up in both, which is exactly why people confuse them — name which one you're doing.
  • "Users must stay connected to the same server" is a prompt to push back. Ask what state lives there and whether it can be externalised, and only accept affinity for genuinely connection-scoped things.
  • Pitfall: treating health checks as a checkbox. If you can describe the detection interval, what the probe does not check, the panic threshold, and what happens to in-flight requests during a deploy, you are answering at a level most candidates never reach.
←previousWalkthrough: URL shortener↑ CovernextCDNs→