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 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.
| 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 | 0.5-2 ms; 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 the correct default for a stateless web tier |
| 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 | Almost nothing — this is the quiet best-in-class default | Any 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 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 default to it rather than to true least-connections, and mentioning it is a cheap, genuine seniority signal.
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.
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 |
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
preStopsleep 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: 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.
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; assume 5-15 minutes of residual traffic to a withdrawn record no matter what TTL you set |
| 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 (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.