Spreading traffic with load balancers
Open in the interactive app: practice, quiz, mock interview →In one minute
- A load balancer gives clients one stable address and spreads their connections or requests across a pool of interchangeable backends.
- Its second job matters as much as the first: it stops sending traffic to backends that are dead, slow or being retired, so clients rarely see a failure.
- Layer 4 balancers forward TCP or UDP flows without reading them; Layer 7 balancers terminate the connection, parse HTTP or gRPC, and route each request.
- Real deployments stack them: DNS or anycast picks a site, an L4 tier spreads flows across L7 proxies, and the proxies balance requests across services.
- The load balancer is itself a shared dependency, so it must be redundant, sized with headroom and watched closely.
How it works
One address, many servers
Without a load balancer, every client must know every server, notice when one dies, and pick among them. A load balancer moves that knowledge into one place: clients connect to a virtual IP (VIP) or hostname, and behind it sits a pool (HAProxy calls it a backend, Envoy a cluster, AWS a target group) of servers that can each handle any request.
This is the overload and partial-failure problem shape at its plainest. The same pattern routes payment calls across gateway instances and an agent's model calls across inference endpoints: one front door, a pool, and a rule for who may receive work.
Layer 4 versus Layer 7
A Layer 4 balancer looks at the IP and TCP or UDP headers, picks a backend for each new flow, and forwards every packet of that flow to the same place. It never reads the payload, so it is fast and protocol-agnostic, but it cannot route by URL, retry a failed request, or see that one long-lived connection carries thousands of requests.
A Layer 7 balancer is a reverse proxy. It accepts the client's TCP and TLS connection, reads each HTTP request, then sends it over a separate connection to a backend it chooses. That makes path- and header-based routing, retries, timeouts, compression, auth and per-request metrics possible. The price is CPU for parsing and TLS, and two sets of socket buffers per client.
Health checks and ejection
Active health checks probe each backend on a timer, for example an HTTP GET /healthz every 2 seconds. A backend is marked down after fall consecutive failures and back up after rise consecutive successes; the thresholds stop one dropped packet from flapping a server in and out. Passive checks, which Envoy calls outlier detection, watch real traffic and eject a backend after, say, 5 consecutive 5xx responses. Passive checks react fast under load; active checks catch dead servers that get no traffic. Use both. Managed balancers now do the passive half too: AWS's Application Load Balancer runs anomaly detection on every target group, comparing each target's error rate with its peers', and with the weighted random algorithm it can shift traffic away from the outlier automatically (AWS ALB docs).
How deep a check should go is a real decision. A check that only proves the process is listening misses a full disk; a check that calls the database fails on every server at once when the database blips. Amazon's guidance is to keep fast load-balancer checks to things local to the host and react to dependency failures centrally, and its NLB, ALB and Route 53 fail open, routing to all targets, when none look healthy (AWS Builders' Library).
Draining
Deploys and scale-in remove healthy servers on purpose. Draining stops new connections or requests going to a backend while existing ones finish, up to a deadline. AWS calls it the deregistration delay (300 seconds by default) (AWS ALB docs); HTTP/2 and gRPC servers send GOAWAY so clients finish in-flight streams and open new ones elsewhere (RFC 9113). Without draining, every deploy shows up as a burst of resets.
Where the balancer lives
Balancing can happen in a dedicated appliance or software tier (HAProxy, NGINX, Envoy, a cloud load balancer), in a sidecar next to each client (a service mesh), or inside the client library itself (gRPC's client-side balancing). Fewer hops favour the client side; central control and simpler clients favour a proxy tier.
Down the stack
Layer 1The contract
An L4 balancer promises that every packet of a flow, identified by its 5-tuple (source IP and port, destination IP and port, protocol), reaches the same backend for the flow's life, and that new flows go only to backends currently believed healthy. An L7 balancer promises per-request routing with explicit failure semantics: a request that cannot be served gets a 502 (bad upstream response), 503 (no healthy upstream) or 504 (upstream timeout) instead of hanging. Neither promises instant detection. The worst-case time to stop routing to a dead backend is about interval × fall for active checks, plus any time a cached decision lives on in an open connection.
Layer 2The mechanism
HAProxy configures checks per server, as in server app1 10.0.0.11:8080 check inter 2s rise 2 fall 3 slowstart 30s, where slowstart ramps a recovered server's weight up over 30 seconds; inter 2s, rise 2 and fall 3 are also the defaults (HAProxy docs). Envoy's outlier detection uses consecutive_5xx (default 5), base_ejection_time (default 30 s, multiplied by the number of past ejections and capped by max_ejection_time, 300 s by default) and max_ejection_percent (default 10%) so a bad deploy cannot eject the whole cluster (Envoy outlier detection API). Envoy also has a panic threshold: if fewer than 50% of hosts are healthy, it balances across all hosts, on the theory that health checking is more likely broken than half the fleet; setting it to 0% makes Envoy return 503 no healthy upstream instead (Envoy panic threshold). On the L4 side, Linux IPVS keeps a connection table keyed by 5-tuple; Google's Maglev and Facebook's Katran hash the 5-tuple into a lookup table so any balancer instance picks the same backend without shared state. Maglev has served Google's traffic since 2008, and one machine saturates a 10 Gbps link with small packets (Maglev, NSDI 2016). Katran, which replaced an IPVS design at Meta, keeps an LRU cache of recent flows on top of the hash, and routers spread VIP traffic across Katran hosts with ECMP (Meta). Backends learn the real client address from X-Forwarded-For or the Forwarded header (RFC 7239) at L7, or from the PROXY protocol header at L4.
Layer 3The machine
An L4 balancer built on XDP or DPDK handles millions of packets per second per core, because it rewrites a few header bytes and never copies the payload into user space. With direct server return, responses go from backend to client without passing back through the balancer, which matters because responses are often ten times larger than requests. Katran works only this way: backends hold the VIP on their loopback interface, and the balancer wraps each inbound packet in an IP-in-IP header to reach them. An L7 proxy instead runs an epoll_wait loop over two sockets per client, each with kernel buffers of tens to hundreds of KB, and pays for TLS: a full handshake costs roughly 0.1 ms of CPU with ECDSA P-256 and closer to 1 ms with RSA-2048, so keep-alive and session resumption are what make TLS termination affordable. Each upstream connection from one proxy IP to one backend IP and port uses an ephemeral port; Linux's default range of 32768 to 60999 allows about 28,000 (kernel docs), which is why proxies pool upstream connections instead of opening one per request.
Why it's built this way
- One machine cannot serve all traffic, and clients should not track a changing fleet, so a stable address fronts a pool.
- Servers fail and deploy constantly, so the balancer owns health and draining instead of every client.
- Reading requests costs CPU, so the cheap L4 tier spreads flows and the expensive L7 tier scales out behind it.
- A balancer tier that keeps per-flow state is hard to scale and fail over, so modern L4 designs use consistent hashing on the 5-tuple and stay nearly stateless.
Diagram
sequenceDiagram participant C as Client participant L4 as L4 tier participant L7 as L7 proxy participant A as Backend A participant B as Backend B L7->>A: GET /healthz (every 2s) A-->>L7: timeout x3, mark DOWN C->>L4: TCP SYN to VIP L4->>L7: forward flow (hash 5-tuple) C->>L7: TLS + GET /orders L7->>B: pick healthy backend B B-->>L7: 200 OK L7-->>C: 200 OK L7->>B: drain: no new requests
Worked example
An orders API serves 24,000 requests per second at peak across 12 backends, about 2,000 each. Each backend saturates at 3,000, so the fleet has 50% headroom.
Backend 7 hangs on a full disk. It still accepts TCP connections, so an L4-only setup keeps sending it one twelfth of new flows. With active checks at inter 2s fall 3 and a 1-second timeout, detection takes up to 9 seconds. During that window 2,000 requests per second time out, about 18,000 failed requests. Adding passive checks with consecutive_5xx: 5 and a 2-second request timeout ejects it after the first few timed-out requests, within a couple of seconds, cutting failures to a few thousand. A single retry on a different backend for idempotent GETs hides most of the rest.
Now the remaining 11 backends carry 24,000 / 11, about 2,180 each, which is fine. If three backends failed, the remaining nine would each take 2,670, close to saturation. That is the number that sizes the fleet: how many simultaneous failures it must absorb at peak.
For the balancer itself, perhaps 1,500 new TLS connections per second arrive with keep-alive. At 0.1 ms of CPU each with ECDSA that is 0.15 cores, small next to proxying. Run at least two proxies per zone, sized so one zone can be lost.
In the wild
- AWS, 2012: a maintenance job deletes ELB state on Christmas Eve. A developer with standing production access ran a maintenance process against live ELB state data in US-East and deleted part of it. At peak 6.8% of load balancers were affected, and others could not be scaled or changed; almost all affected load balancers were restored by 10:30 AM PST on Christmas Day, about 22 hours after it began. AWS made that access require per-incident approval and planned automatic reconciliation of control-plane state (AWS).
- Google Cloud, 2021: a config race turns the global load balancer into a 404 machine. A race condition, present for about six months in the pipeline that pushes customer configs to the external proxy load balancer, shipped a corrupted file. Most GCLB endpoints returned 404 for about 33 minutes, taking Cloud Run, App Engine, Cloud Functions, Apigee and Firebase routes with them. Google rolled back to the last good config and added pre-apply correctness checks (Google Cloud).
- HubSpot, 2024: readiness probes eject an overloaded routing tier. Envoy routing pods were packed unevenly onto Kubernetes nodes, and a fix shipped the day before had silently not taken effect. In a morning surge, many pods on hot nodes failed readiness at once and were removed, overloading the rest; two outages totalled about 52 minutes. HubSpot rolled back to NGINX and limited how many pods can be removed at once under load (HubSpot).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| L4 balancer | Very fast, any protocol, nearly stateless with hashing | No request routing, retries or per-request metrics | Front tier, non-HTTP protocols, raw throughput |
| L7 proxy | Routing by path or header, retries, TLS, observability | CPU and latency cost, must parse every protocol | HTTP and gRPC services, edge and internal |
| Client-side balancing | No extra hop, no central bottleneck | Logic in every client and language; harder to change | Internal gRPC with a uniform client stack |
| Sidecar mesh | Uniform policy without changing apps | Extra proxy per pod, memory and operations cost | Many services in many languages |
| DNS only | Simple, global | Coarse, cached, slow to react | Choosing a region, not a server |
Pitfalls in production
- Shallow health checks. A
/healthzthat returns 200 while the database is unreachable keeps a broken server in rotation. Check the dependencies the server needs to do useful work, but keep the check cheap. - Deep health checks that cascade. If every backend fails its check when a shared dependency blips, the balancer ejects the whole fleet. Use a panic threshold or
max_ejection_percent, and let the backends degrade instead. The same cascade happens with overload: backends too busy to answer probes get pulled, which pushes more load onto the rest. - Long-lived connections stay put. HTTP/2 and gRPC multiplex thousands of requests on one connection, so an L4 balancer pins them all to one backend. Use L7 balancing for these protocols, or recycle connections with a max age.
- No draining on deploys. Killing processes without deregistering them first shows up as resets on every rollout. Deregister, wait for the drain, then stop.
- The balancer as a single point of failure. One proxy instance, or one zone, takes down everything behind it. Run redundant instances across zones and test failover.
Leadership lens
- Ask how long it takes from a backend dying to traffic stopping, in seconds, and how many failed requests that costs at peak.
- Decide which team owns the balancer configuration; shared edge proxies become a bottleneck for every team's changes unless ownership and self-service are clear.
- Size the fleet for N plus the number of simultaneous failures you must survive at peak, and check the balancer tier against the same rule.
Check yourself
A backend accepts TCP connections but every request hangs. Which kinds of health checking catch it, and how quickly?
Show answer
An HTTP active check with a timeout catches it after about
interval × fallseconds. Passive checks catch it after a few failed or timed-out real requests, which is faster under load. A TCP-only check never catches it.Why might an L4 balancer leave one gRPC backend at 90% CPU while others idle?
Show answer
gRPC multiplexes many requests on a few long-lived connections, and an L4 balancer places connections, not requests. A few busy connections pinned to one backend stay there.
What is the purpose of Envoy's panic threshold?
Show answer
When most hosts look unhealthy, the health checking is likely wrong or a shared dependency is down. Spreading traffic across all hosts is better than piling it on the few that still pass.
Why does direct server return help an L4 tier scale?
Show answer
Responses are usually much larger than requests, and with DSR they go straight from backend to client, so the balancer only handles the small inbound half of the traffic.
Sources and further study
Primary
- Eisenbud et al., Maglev: A Fast and Reliable Software Network Load Balancer (NSDI 2016): consistent hashing and connection tracking in a stateless L4 tier.
- RFC 9113: HTTP/2:
GOAWAYand graceful shutdown, the basis of draining multiplexed connections.
Systems docs
- AWS ALB target group attributes: deregistration delay, slow start, routing algorithms and automatic target weights.
- Envoy panic threshold: what happens when too few hosts look healthy.
- HAProxy configuration manual: every check, rise, fall and slow-start parameter with defaults.
Books
- Site Reliability Engineering, Beyer et al., chapter 19 "Load Balancing at the Frontend": DNS and VIP balancing before traffic reaches a data centre.
- Understanding Distributed Systems, Roberto Vitillo, chapter "Network load balancing": DNS, L4 and L7 balancing and health checks in one short chapter.
- Release It! (2nd ed.), Michael Nygard, chapter 9 "Interconnect": load balancers among the pieces that connect tiers, and how they fail.
Other curricula
- AWS Builders' Library: Implementing health checks: shallow, local and dependency checks, and when to fail open.
- donnemartin/system-design-primer: covers L4 versus L7 and active-passive pairs in a page, framing the balancer mainly as a way to remove single points of failure.
Watch
- What is a Load Balancer? (IBM Technology): short whiteboard overview of what a balancer does and why.
- Load balancing in Layer 4 vs Layer 7 with HAPROXY Examples (Hussein Nasser): the L4 and L7 difference shown on a real proxy.
Free resources
- How Elastic Load Balancing works (AWS): zones, cross-zone balancing, and routing in a managed balancer.
- Envoy outlier detection: passive health checking, ejection times and limits in detail.
- Envoy health checking: active checks, thresholds and how they combine with passive ones.
- Open-sourcing Katran (Meta Engineering): an XDP-based L4 balancer and why it is built that way.
- Unimog, Cloudflare's edge load balancer: L4 balancing across a whole data centre without dedicated balancer machines.
- Load Balancing in the Datacenter (Google SRE book): subsetting, lame-duck state and how Google balances internally.