L4 vs L7, round-robin vs least-connections vs hashing, health checks that lie, and connection draining during a deploy.
Module F-11 — Load Balancing
What this module covers: The component every request passes through and almost nobody designs deliberately. This module covers what a load balancer actually does beyond distribution, the real difference between L4 and L7 and what each can and cannot do, the distribution algorithms and why least-connections beats round-robin for tail latency, and then the two subjects that cause real outages: health checks that turn one dependency blip into a total outage, and connection draining that drops in-flight requests on every deploy. Plus the layers of load balancing that stack on top of each other, from DNS down to a sidecar proxy.
A Load Balancer Does Four Jobs
Distribution is the obvious one and the least interesting. The full list:
- Distribute requests across healthy backends.
- Detect health and stop sending traffic to instances that can't serve it.
- Terminate TLS, so backends speak plaintext internally and the expensive handshake happens once, close to the user (Module F-5).
- Drain — remove an instance from rotation without dropping the requests it's already handling.
Jobs 2 and 4 are where the incidents live. Job 1 is a configuration choice; jobs 2 and 4 are design decisions with failure modes.
Analogy: a load balancer is the host at a restaurant. Seating guests evenly is the easy part. The hard parts are noticing that one section's waiter has walked out (health checks) and closing a section without ejecting the people mid-meal (draining). A host who mishandles either of those empties the restaurant.
L4 vs L7: What Each Can See
L4 (transport layer) balancers forward TCP connections. They see IP addresses and ports and nothing else — the payload is opaque, which is the point: it's cheap, extremely fast, and works for any TCP protocol.
L7 (application layer) balancers terminate the connection, parse HTTP, and make decisions per request. They see method, path, headers, and cookies.
| L4 | L7 | |
|---|---|---|
| Unit of distribution | Connection | Request |
| Can route by path/header | No | Yes |
| Can retry a failed request | No (it doesn't know what a request is) | Yes |
| Can rewrite, compress, add headers | No | Yes |
| Sees TLS content | No (unless terminating) | Yes (terminates) |
| Overhead | Very low | Higher (parsing, buffering, re-encryption) |
| Works with | Any TCP protocol | HTTP, gRPC, WebSocket upgrades |
The decisive point, and it follows straight from Module F-6: with HTTP/2 or gRPC, one client holds one long-lived connection carrying thousands of requests. An L4 balancer distributing connections therefore pins all of that traffic to one backend. So:
- HTTP/1.1 traffic: L4 is often fine; connection count roughly tracks request count.
- HTTP/2, gRPC, or any long-lived-connection protocol: you need L7 request-level balancing, or client-side balancing, or a mesh sidecar (Module A-5). L4 will distribute connections beautifully and load terribly.
There's also a hybrid worth knowing: an L4 balancer can route by TLS SNI without decrypting, which gives you per-hostname routing while keeping end-to-end encryption. Useful when compliance forbids terminating TLS at the edge.
Algorithms, and What They're Actually Optimising
Round-robin — next backend in sequence. Simple, and correct only when every request costs the same and every backend is identical. Neither is usually true.
Weighted round-robin — proportional to declared capacity. The standard answer for a mixed fleet during a migration between instance types.
Least connections — send to the backend with the fewest in-flight requests. This is the better default for most HTTP services, and the reason is Module F-4: request costs vary enormously (the 900-line order next to the 2-line one). Round-robin keeps feeding an instance that's already stuck behind expensive work; least-connections naturally routes around it, because the stuck instance's in-flight count stays high. It's a feedback signal rather than a fixed rotation.
Least response time — least connections weighted by observed latency. Better still in theory; more sensitive to noisy measurements, and it can amplify oscillation if the measurement window is short.
Hash-based (consistent hashing) — route by a hash of client IP, a cookie, or a URL. Two legitimate uses:
- Session affinity without server-side session storage — with all four of Module F-9's costs.
- Cache locality: hashing on the request path means the same URL always reaches the same backend, so its in-process cache stays warm. This is genuinely valuable for cache-heavy services, and consistent hashing (Module P-7) is what keeps it from reshuffling every key when the backend count changes.
Power of two random choices — pick two backends at random, send to the less loaded. Deserves to be better known: it gets most of the benefit of least-connections while needing almost no global state, which matters when the balancing decision is made by many independent clients or sidecars that can't see each other's counts.
Health Checks: How to Cause a Total Outage
This is the most valuable section in the module, because the failure is severe, common, and produced by well-intentioned engineering.
Two distinct questions, often conflated:
- Liveness: is this process alive and should it be restarted if not?
- Readiness: can this instance serve traffic right now?
The tempting mistake is a "thorough" health check:
// ⚠️ This design converts a database blip into a full outage.
app.get('/health', async (req, res) => {
await db.query('SELECT 1'); // is the database up?
await redis.ping(); // is the cache up?
await paymentProvider.status(); // is the payment vendor up?
res.send('ok');
});
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion