I1 · 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.
10On this page
- 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
- Connection draining, and the deploy that drops requests
- Sticky sessions and why they are a smell
- Who balances the balancer?
- Multi-tier balancing: DNS, global, regional
- 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 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.
| Capability | L4 | L7 | Why it matters |
|---|---|---|---|
| Route by host / path / header | No | Yes | One public IP fronting twenty services; canary by header; API versioning at the edge |
| Retry a failed request elsewhere | No — it can only reset the connection | Yes | L4 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 gRPC | No | Yes | The 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 management | Passthrough only | Yes | Centralised certificates; backends speak plaintext or mesh mTLS |
Inject X-Forwarded-For, trace ids | No | Yes | Without it, every backend log shows the balancer's IP and distributed tracing has no root span |
| Rate limiting, WAF, response caching, compression | No | Yes | Per-route policy in one place |
| Cost and latency | Microseconds; near-zero CPU | Roughly 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 it | At 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 L7 | Anything HTTP where you need routing, retries, observability or per-request fairness — which is nearly every application tier | Real systems use both, in that order |
Algorithms, and when each one is right
| Algorithm | How it decides | Fails when | Reach for this when… |
|---|---|---|---|
| Round robin | Next backend in sequence | Requests vary in cost, or backends vary in size — a slow request pins a server while the rotation keeps feeding it | Homogeneous backends and roughly uniform request cost. Still a sound default for a stateless web tier (and Envoy's stock policy) |
| Weighted round robin | Proportional to a static weight | Weights are guesses and go stale after a hardware refresh | Mixed instance sizes, or ramping a canary from 1% to 100% |
| Least connections | Fewest in-flight connections | Each balancer only sees its own connections; with many balancers they all pick the same "idle" backend at once and stampede it | Long-lived or highly variable requests: WebSockets, streaming, uploads, slow queries |
| Least request / peak-EWMA | Fewest outstanding requests, weighted by observed latency | Needs per-backend latency state; reacts to noise if the window is too short | Service-to-service traffic behind a mesh, where a degraded backend must be shed automatically |
| Power of two choices | Pick two backends at random, send to the less loaded of the two | It needs a load signal per backend, and a stale signal still herds; weighted backends need a variant | Any 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 hashing | Hash a key onto a ring; take the first node clockwise | Hot keys — one popular key means one hot backend, permanently | Cache tiers, sharded stateful services, session affinity without cookies. See below |
| Source-IP hash | Hash the client IP | Carrier-grade NAT — an entire mobile network arrives as one IP and lands on one backend | Rarely. 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.
| Request | Duration | Arrives | Round robin target | RR wait before starting | Least-connections target | LC wait before starting |
|---|---|---|---|---|---|---|
| R1 | 10s | t=0 | S1 | 0 — S1 idle | S1 | 0 — all idle, tie-break to S1 |
| R2 | 1s | t=0 | S2 | 0 — S2 idle | S2 | 0 — S2 has fewest connections (0) |
| R3 | 1s | t=0 | S3 | 0 — S3 idle | S3 | 0 — S3 has fewest connections (0) |
| R4 | 1s | t=1 (R2, R3 just finished) | S1 — next in the cycle | ~9s — S1 is still 9s into R1 | S2 or S3 — both idle, S1 has 1 active | 0 — routed away from the busy server |
| R5 | 1s | t=2 | S2 — next in the cycle, idle | 0 | whichever of S2/S3 is idle | 0 |
| R6 | 1s | t=3 | S3 — next in the cycle, idle | 0 | whichever of S2/S3 is idle | 0 |
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.
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 probing | Passive (outlier detection) | |
|---|---|---|
| Mechanism | The balancer calls /healthz every N seconds | The balancer watches real responses and ejects after k consecutive 5xx or timeouts |
| Detection time | interval × unhealthy-threshold, plus the timeout — 5 s × 3 is up to ~20 s of served errors | Effectively immediate — it fails on real traffic |
| Blind spot | A 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 |
| Risk | Probe traffic at scale, and coupling to dependencies | Ejecting 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 ones | Always, 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
preStopsleep 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: closeon the last response (or an HTTP/2GOAWAY), 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.
| Tier | Chooses | Failover speed | Gotcha |
|---|---|---|---|
| DNS / GeoDNS | Which region's IP the client gets | Minutes, and not really yours to control | TTL 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) |
| Anycast | Which POP the packets reach, via BGP | Seconds | Immune 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 L4 | Which L7 proxy gets the flow | Seconds, per flow | Use Maglev-style consistent hashing so scaling the L4 tier doesn't reshuffle every existing connection |
| Regional L7 | Which backend gets each request | Immediate, per request | The only tier that can retry. Also the only tier that can be zone-aware |
| Client-side / mesh sidecar | Which instance, from the caller's own process | Immediate | Removes 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.
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.
Finished reading?
0 of 36 chapters read in this series