Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Intermediate/I1
8 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDI1 · Part 2 · Intermediate

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.

22 min read3 diagrams10 sections0 exercises
8 of 36

In this chapter

  • The most-drawn, least-understood box on the whiteboard
  • L4 vs L7: what only L7 can do
  • Algorithms, and when each one is right
  • Consistent hashing, properly
  • Health checks: active, passive, and how they amplify an outage

and 5 more

about 22 min
10On this page
  1. The most-drawn, least-understood box on the whiteboard
  2. L4 vs L7: what only L7 can do
  3. Algorithms, and when each one is right
  4. Consistent hashing, properly
  5. Health checks: active, passive, and how they amplify an outage
  6. Connection draining, and the deploy that drops requests
  7. Sticky sessions and why they are a smell
  8. Who balances the balancer?
  9. Multi-tier balancing: DNS, global, regional
  10. Recognizing it in an unseen problem

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 adds roughly 0.5-2 ms (planning figure) 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 (DSR) the response never traverses the balancer at all, which matters enormously for video and download workloads. The trade-off is that an L4 proxy that opens its own connection to the backend hides the client IP from it unless you pass it along with the PROXY protocol header (DSR and NAT-style forwarding keep the source address). 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 CPURoughly 0.5-2 ms (a planning figure; benchmark your proxy and request mix); 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 a sound default for a stateless web tier (and Envoy's stock policy)
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 twoIt needs a load signal per backend, and a stale signal still herds; weighted backends need a variantAny fleet with multiple independent balancers. It needs no global state and drops the expected maximum load from roughly log n / log 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 offer it instead of true least-connections: Envoy's least-request policy, for instance, samples two random hosts by default (choice_count of 2) and picks the one with fewer active requests, though you opt in to it and round robin is the stock policy. 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 tens of seconds to a couple of minutes. It is one config line and it is the difference between a deploy nobody notices and a deploy that pages someone.

Dry run: round robin versus least connections, with one slow request in the mix

Three backends, all idle at t=0. R1 takes 10s; every other request takes 1s. Requests are routed the instant they arrive.

RequestDurationArrivesRound robin targetRR wait before startingLeast-connections targetLC wait before starting
R110st=0S10 — S1 idleS10 — all idle, tie-break to S1
R21st=0S20 — S2 idleS20 — S2 has fewest connections (0)
R31st=0S30 — S3 idleS30 — S3 has fewest connections (0)
R41st=1 (R2, R3 just finished)S1 — next in the cycle~9s — S1 is still 9s into R1S2 or S3 — both idle, S1 has 1 active0 — routed away from the busy server
R51st=2S2 — next in the cycle, idle0whichever of S2/S3 is idle0
R61st=3S3 — next in the cycle, idle0whichever of S2/S3 is idle0

R4 is the entire lesson: round robin's turn-taking is blind to what's in flight, so it sends a fast, cheap request to the one server already occupied by a slow one and makes it queue for 9 seconds behind work it has nothing to do with. Least connections looks at actual load before deciding and routes R4 to an idle backend for free.

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, losing one node out of five (so the pool goes from five to four) 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 have two guardrails, and it matters which one fires when. The first is a cap on ejections: Envoy's outlier detection ejects a host after 5 consecutive 5xx responses, for a base time of 30 seconds, and by default will not eject more than 10% of the cluster, so passive ejection alone cannot walk the fleet from ten hosts down to six. The second is the panic threshold (Envoy's default is 50%: when fewer than half of the hosts are available, ignore health status and spread load across all of them, on the theory that a struggling backend beats no backend). Note the arithmetic: six of ten healthy is 60%, so the default panic threshold would not yet have engaged in the story above; it would only trigger at four of ten. Which is why you want both guardrails, plus the capacity headroom to survive them. The other half of the fix belongs to the backend: enforce a concurrency limit and shed excess load with a fast rejection rather than degrading into timeouts (mind the status code: Envoy's passive ejection counts every 5xx, so a shed 503 can feed the ejection it is meant to prevent unless you use a non-5xx code such as 429 or configure around it), 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. Large client-to-backend meshes also limit connection counts with deterministic subsetting, as described in the Google SRE book: each client talks to a fixed-size subset of backends.

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 (a "lame duck" state: still listening, but telling clients to stop sending new requests, as the Google SRE book puts it) 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.

Who balances the balancer?

A load balancer is a single point of failure unless you make it not one. The common answers, from simplest to largest: an active-passive pair sharing a virtual IP (VRRP, as in keepalived), which fails over in seconds but wastes the standby and caps throughput at one box; an active-active L4 tier behind ECMP, where the upstream router spreads flows across many identical L4 machines by hashing the 5-tuple, and consistent hashing (Google's Maglev design) keeps existing connections on the same backend even as L4 machines come and go; and anycast, where many sites announce the same address and BGP delivers each packet to a nearby healthy one. Managed cloud balancers are exactly these designs, run for you, so "I'd use the cloud LB" is a fine answer once you can say what is underneath it.

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; plan for roughly 5-15 minutes of residual traffic to a withdrawn record no matter what TTL you set (a planning figure; a tail of clients can take longer)
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 (AWS lists about one cent per GB in each direction, so two cents per GB end to end; check your provider's current price sheet) and adds 1-2 ms of round trip. 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.
Before you move on
  • Explain why round robin can pin a fast request behind a slow one, using the R4 trace above.
  • Compute how many keys move when a consistent-hashing ring grows from 4 to 5 nodes, versus mod-N, and say why the gap matters for a cache's hit rate.
  • Explain why an L4 balancer produces permanent hotspots in front of gRPC or HTTP/2, while an L7 balancer doesn't.
  • Walk through the health-check cascade in the warning box — ten backends down to six — and say what an ejection cap and a panic threshold each do to stop it (and why 60% healthy does not trigger a 50% panic threshold).
  • Say how the load balancer itself avoids being a single point of failure.
  • Explain why sticky sessions are "affinity you depend on for correctness" while consistent hashing is "affinity you exploit for performance," and what breaks differently in each when a node dies.
Sources and further reading
  • Envoy: load balancers — least-request with two random choices, ring hash and Maglev
  • Envoy: panic threshold — the 50% default and what happens below it
  • Envoy: outlier detection settings — consecutive 5xx, ejection time and the 10% ejection cap
  • Maglev: a fast and reliable software network load balancer (NSDI 2016) — ECMP in front of consistent-hashing L4 balancers
  • Google SRE book: load balancing in the datacenter — deterministic subsetting, lame duck state and load-weighted round robin
  • The power of two choices in randomized load balancing (Mitzenmacher) — the analysis behind sampling two backends and taking the less loaded
  • AWS VPC pricing — cross-AZ transfer charged per GB in each direction

Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

I1

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. B7Previous · B7Walkthrough: a URL shortener
  2. I1you are here
  3. Next · I2CDNsMove the bytes closer to the user — the cheapest 10x in system design, right up to the moment you cache something personal.23 min read

01On this page

10 sections · 0%

  1. 01The most-drawn, least-understood box on the whiteboard
  2. 02L4 vs L7: what only L7 can do
  3. 03Algorithms, and when each one is right
  4. 04Consistent hashing, properly
  5. 05Health checks: active, passive, and how they amplify an outage
  6. 06Connection draining, and the deploy that drops requests
  7. 07Sticky sessions and why they are a smell
  8. 08Who balances the balancer?
  9. 09Multi-tier balancing: DNS, global, regional
  10. 10Recognizing it in an unseen problem

[] chapters · t top