Load Balancing
A load balancer turns N servers into one address so capacity and availability stop depending on a single machine — and the algorithm, the health check and the balancer’s own redundancy each decide whether it helps or hurts under load.
One server is both a capacity ceiling and a single point of failure. A load balancer spreads requests over several servers behind one address, so you can add capacity by adding machines and lose a machine without losing the service.
The problem: one box is a ceiling and a single point of failure
A single application server saturates at some request rate — say 1,200 req/s at 100% CPU with p99 climbing past 800 ms — and when it reboots, the service is gone. Adding a second server does nothing until something decides which server each request goes to. That something is the load balancer: it owns the public address, keeps a list of backends, and forwards each request to one of them. Clients only ever see one endpoint.
The balancer sits on every request, so its two jobs are to spread work so no backend is hotter than the others, and to stop sending work to backends that are dead or dying. Everything else — TLS termination, routing by path, rate limiting — is optional and pushes it toward being an API Gateway.
Algorithms, and the problem each one solves
Round robin hands requests out in turn and is right when requests cost about the same. Weighted round robin exists because fleets are rarely uniform — a 16-core box next to 8-core boxes should get twice the share. Least connections exists because requests are not uniform either: one slow report request pins a server, and round robin keeps feeding it while it is busy; least connections routes to whichever backend currently has the fewest in-flight requests. Power of two choices picks two backends at random and takes the less loaded — nearly as good as global least-connections with none of the shared state, which is why fleets of balancers use it.
Consistent hashing answers a different question: not "who is least busy" but "who already has this key". Hash the user id or cache key onto a ring and send all requests for that key to the same backend, so its in-process cache stays warm. When a backend leaves, only its keys move — about K/N of them, not all of them as with hash mod N. The mechanism is its own lesson: Consistent Hashing; the data structure underneath is a Hash Table with a sorted ring on top.
| Algorithm | Solves | Breaks when | State needed |
|---|---|---|---|
| Round robin | Uniform requests, uniform servers | One slow request type pins a server | A counter |
| Weighted round robin | Mixed instance sizes | Weights go stale after a resize | Weights per backend |
| Least connections | Variable request cost | Backends with different capacity look equal | In-flight count per backend |
| Power of two choices | Many balancers, no shared counters | Very small pools (2–3 backends) | Local counts only |
| Consistent hashing | Cache affinity, per-key locality | Hot keys; nodes without virtual nodes | The ring |
Health checks — and the danger of marking everything unhealthy
Active checks probe each backend on a timer (GET /healthz every 5 s, unhealthy after 3 failures, healthy again after 2 successes). Passive checks watch real traffic: N consecutive 5xx or connection resets eject the backend for a cool-down. Use both — active checks catch a dead process fast, passive checks catch a backend that answers /healthz fine while every real request times out on a locked database.
The trap: a health check that depends on a shared dependency. If /healthz pings the database and the database stalls for 10 s, every backend fails its check simultaneously, the balancer ejects all of them, and a database blip becomes a total outage — with the balancer returning 503 to everyone even after the database recovers, until checks pass again. Two defences: keep the liveness check shallow (is the process up and able to serve), and use a panic threshold — when more than, say, 50% of backends are unhealthy, ignore health and route to all of them, because a degraded backend beats none.
- Liveness (process alive) and readiness (can take traffic right now) are different checks; a deploying instance is alive but not ready.
- Eject after several failures, not one; a single 2 s hiccup should not remove a server for 30 s.
- Slow-start: bring a recovered backend back at 10% weight and ramp, or its cold caches and JIT make it slow, it trips the passive check, and it is ejected again.
Sticky sessions, L4 vs L7, and why stickiness fights scaling
Sticky sessions pin a client to one backend (by cookie or source IP) so a server that keeps session state in memory keeps seeing the same user. It works until it does not: a scale-in event or a deploy kills the pinned server and the user is logged out; a NAT gateway puts 5,000 corporate users behind one IP and they all land on one backend; autoscaling adds servers that get no traffic because every existing client is pinned elsewhere. Stickiness is a workaround for stateful servers; the real fix is Stateless vs Stateful Services — move the state out and let any backend serve any request.
A layer-4 balancer forwards TCP/UDP by IP and port: it never looks inside the connection, so it is fast, protocol-agnostic and can pass TLS straight through. A layer-7 balancer terminates HTTP: it can route /api/orders to the order pool, read cookies for stickiness, retry an idempotent GET on a different backend, and observe status codes for passive health. L7 costs CPU per request and must hold certificates; most systems run L4 in front for raw throughput and L7 behind it for routing decisions.
| Property | L4 (TCP) | L7 (HTTP) |
|---|---|---|
| Sees | IP, port, connection | Method, path, headers, cookies, status |
| Routing granularity | Per connection | Per request |
| TLS | Pass-through or terminate | Terminates (needs certificates) |
| Retry a failed request elsewhere | No | Yes, for idempotent methods |
| Throughput per core | Very high | Lower; parses every request |
The balancer itself is a single point of failure
You removed the single server and added a single balancer. It is made redundant the same way everything is: more than one, and a way to fail between them. DNS can return several balancer IPs, but resolvers cache records, so a dead IP keeps receiving traffic for the TTL — DNS is a coarse tool. An active–passive pair shares a virtual IP that moves to the standby on failure (VRRP/keepalived), which is seconds of failover with no client change. Anycast advertises the same IP from many locations and lets BGP route each client to the nearest live one — how large CDNs and cloud balancers stay up. Managed cloud balancers hide all of this behind one hostname and are the right default unless you run your own network.
For scaling, this is rung one of the ladder in Scale This System: the balancer buys horizontal capacity for the stateless tier and hands you the next problem — every backend now opens its own connections to a database that has not grown at all.
Key points
- The balancer owns one address and two jobs: spread load and stop sending to dead backends.
- Round robin for uniform work, least connections for variable work, consistent hashing when a key must keep landing on the same backend.
- A health check that depends on a shared dependency can eject the whole fleet at once; keep it shallow and set a panic threshold.
- Sticky sessions are a workaround for state on the server; they break under deploys, NAT and autoscaling.
- Make the balancer redundant with a VIP pair, anycast or a managed service; DNS alone fails over slowly.
Round robin, least connections, consistent hashing
How data moves through it
One request or event, hop by hop.
- 1Client → DNS: resolves
api.example.comto the balancer’s virtual or anycast IP. - 2Client → LB: TCP (L4) or TLS+HTTP (L7) connection lands on the balancer.
- 3LB → backend: chosen by the algorithm from the healthy set; request forwarded, often with
X-Forwarded-Foradded. - 4Backend → DB/cache: the backend does its work; the balancer never sees this hop.
- 5Backend → LB → client: response passes back; an L7 balancer records the status for passive health.
When to use — and when not
- More than one instance of a stateless service must share one address, for capacity or for surviving a machine loss.
- Rolling deploys: drain one backend, deploy, re-add, without clients noticing.
- Routing by path or host to different pools (L7), or TLS termination in one place.
- A single instance handles the measured load with headroom and a short outage is acceptable; a balancer in front of one server is only a health check with extra latency.
- Internal service-to-service calls in a mesh with client-side discovery — the client already picks an instance; see Service Discovery.
- Stateful protocols where a client must stay on one node (a WebSocket that owns a game room) unless the balancer supports hashing and drain.
Tradeoffs
Cheap and standard; the cost is one more hop (~0.5–2 ms for L7) and a new component whose health-check policy can take the whole fleet offline at once.
How it fails
- Deep health check on a shared dependency: one database stall makes all backends fail the check together and the balancer serves 503 to everyone.
- Sticky sessions plus autoscaling: new instances receive almost no traffic while old ones stay hot; a scale-in logs users out.
- Retrying a non-idempotent POST on another backend after a timeout: the first backend completed it, so the customer is charged twice.
- Uneven load with round robin when one endpoint is 50× more expensive than the others; least connections or separate pools fix it.
- Connection amplification: 20 backends × 50 pooled connections = 1,000 database connections the database was never sized for.
How it scales
- Add backends until the balancer itself saturates — an L4 balancer handles millions of connections; an L7 balancer is bounded by CPU for parsing and TLS.
- Scale the balancer tier with anycast or DNS across several balancers, and shard by hostname or path before one balancer becomes the bottleneck.
- The backend tier scales linearly only while it is stateless; the next ceiling is whatever the backends share — the database.
How it interacts with databases, queues, caches, APIs and external systems
- Database: invisible to the balancer, but N backends each pooling connections multiply load on it; size pools per instance.
- Cache: consistent-hash routing keeps in-process caches warm; with a shared Redis the algorithm no longer matters for affinity.
- API gateway: an L7 balancer with routing, auth and rate limiting is a gateway; keep business logic out of it.
- Service discovery: the backend list must update as instances start and stop; a static list sends traffic to dead IPs.
- External: TLS certificates and DNS records live here; certificate expiry is a classic balancer-level outage.