CS Fundamentals Trainer · Guides
Load balancing
Layer 4 versus layer 7, a simulation of random, round robin, least connections and power of two choices, health checks, draining and sticky sessions.
12 min read
A load balancer sits in front of several identical servers and spreads incoming requests across them. It gives you three things at once: more capacity than one machine, availability (a dead server stops receiving traffic), and freedom to deploy (take servers out one by one). The interesting part is the choice of which server gets each request, and how the balancer knows which servers are healthy. This guide includes a simulation, so the differences between policies come with numbers instead of adjectives.
1. Where it sits and what it does
Diagram: A load balancer in front of three servers, with health checks
Its jobs: pick a backend for each request or connection, stop sending traffic to unhealthy backends, and often also terminate TLS, compress, rate limit, and add headers (see the TLS guide). The balancer itself must not be a single point of failure, so in practice there are two or more, sharing one address or sitting behind DNS (section 8).
2. Layer 4 or layer 7
| Layer 4 (transport) | Layer 7 (application) | |
|---|---|---|
| Decides using | IP addresses and ports | The HTTP request: host, path, headers, cookies |
| Sees the content | no | yes |
| Cost | very low (forward packets or connections) | higher (parse requests, often terminate TLS) |
Can route /api and /images differently | no | yes |
| Can retry a failed request on another server | only at connection time | yes, per request |
| Examples | AWS Network Load Balancer, HAProxy in TCP mode, IPVS | NGINX, Envoy, HAProxy in HTTP mode, AWS Application Load Balancer |
A common arrangement uses both: a layer 4 balancer spreads connections across a pool of layer 7 proxies, which route to services. One detail matters for layer 7 and HTTP/2: a client keeps one long connection and multiplexes many requests over it, so a layer 4 balancer that balances connections can send all of one busy client's requests to one server. For gRPC and HTTP/2 traffic balance per request at layer 7.
3. Choosing the backend
Here are the four policies you should know, then a simulation that compares them.
- Random: pick any server. No state at all.
- Round robin: go through the servers in order. (Weighted round robin gives bigger servers a larger share.)
- Least connections: send to the server with the fewest requests in flight. Needs a count per server.
- Power of two choices (p2c): pick two servers at random and send the request to the one with fewer requests in flight. For balls thrown into bins, Azar, Broder, Karlin and Upfal showed that choosing the emptier of two random bins cuts the fullest bin from roughly log n / log log n to log log n [1]. Mitzenmacher showed that the same idea works for servers with queues, where it reduces the waiting time dramatically compared with one random choice [2]. Nginx has it built in as
random two least_conn[3].
Two more are used for a different reason:
- IP or key hash: the same client (or the same key) goes to the same server, which helps when servers keep a local cache or in-memory session. Use consistent hashing so that adding a server moves few keys.
- Weighted / latency based: prefer faster or bigger servers, measured from recent response times.
What the simulation shows
I wrote a small simulation: 10 servers, each handling one request at a time with a first-come queue; requests arrive at random so that the servers are 90% busy on average; 200,000 requests. Times are in units of the average service time (1.0). I ran it with three kinds of request, all with the same mean of 1.0: exactly equal sizes; exponentially distributed sizes (the usual textbook model: many short requests, a few long ones); and heavy-tailed (90% short requests, 10% about eleven times longer).
| Policy | Equal sizes: mean / p99 | Exponential sizes: mean / p99 | Heavy-tailed: mean / p99 |
|---|---|---|---|
| Random | 5.7 / 23.1 | 10.3 / 45.1 | 25.4 / 118.7 |
| Round robin | 1.4 / 3.2 | 5.3 / 23.7 | 27.3 / 133.1 |
| Least connections | 1.4 / 3.0 | 1.9 / 7.7 | 3.6 / 27.2 |
| Power of two choices | 1.9 / 4.2 | 3.0 / 10.3 | 6.3 / 34.9 |
(Medians over five runs with different random seeds, in units of the mean request time. The simulation is a model, not a benchmark of any real balancer: every server is identical, there is no network delay, and the load is high on purpose so the differences are visible.)
What to take from it:
- Random is bad at high load: by chance some servers get a run of requests while others sit idle.
- Round robin is excellent only when requests are truly alike. With exactly equal sizes it is as good as least connections (1.4 against 1.4) and better than power of two choices (1.9), and it needs no state. As soon as sizes vary it falls behind: with exponential sizes it is about three times worse than least connections, and with heavy-tailed sizes it is no better than random (27.3 against 25.4). Equal counts of requests are not equal work: a server that draws a few long requests falls behind while the rotation keeps feeding it.
- Looking at actual load wins when sizes vary. Least connections is several times better than random and round robin in the last two columns, and no worse in the first.
- Real traffic is not equal-sized. Requests differ in cost, and servers differ in speed and in how busy their other work is, so the equal-size column is the exception.
- Power of two choices gets most of that benefit while comparing only two servers. That matters at scale: least connections needs exact, up-to-date counts of every server, which is awkward when there are many balancers each seeing part of the traffic. p2c only needs the counts of the two it picks, and stale counts hurt it less.
4. Health checks
A balancer must stop sending requests to a server that is down, and start again when it recovers.
- Passive: watch real traffic. After N failures or timeouts in a row, mark the server down for a while (a circuit breaker for one backend).
- Active: the balancer calls an endpoint on every server, for example
GET /healthzevery 5 seconds, and needs, say, 2 failures to mark it down and 3 successes to mark it up (hysteresis stops flapping).
What the check endpoint should test is a design decision:
- A liveness check says "the process is running". Failing it means restart me.
- A readiness check says "I can serve traffic now" (warmed up, can reach the database). Failing it means take me out of rotation but do not restart.
- A health check that calls the database deep inside every few seconds on every server adds load, and if the database is slow all servers fail the check at once and the balancer removes the whole fleet. Prefer cheap checks, and decide deliberately what happens when everything looks unhealthy (most balancers keep sending traffic anyway, which is usually better than none).
Other behaviour that goes with it:
- Slow start: ramp a new or recovered server up gradually, so a cold cache and cold connection pools are not hit with full load.
- Connection draining: when removing a server for a deploy, stop new requests but let the running ones finish.
- Retries: retry a failed request on another server only if it is safe to repeat (idempotent), and cap the retries, or a struggling system receives more load exactly when it can least afford it. See the rate limiter guide for protecting a service from overload.
5. Sessions and sticky routing
If each server keeps a user's session in its own memory, the second request must go to the same server ("sticky sessions", done by a cookie or by hashing the client). The cost: load is less even, and when that server dies, the sessions are gone.
The usual better answer is to keep servers stateless: put the session in a shared store (Redis, a database) or in a signed token the client holds, so any server can handle any request. Then the balancer can use any policy and servers can come and go freely.
6. Example configuration
The same idea in two real tools (the exact option names are in each tool's documentation [3][4]).
# NGINX: least connections, with passive health checking and a backup server
upstream app {
least_conn;
server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
server 10.0.0.13:8080 backup;
}
server {
listen 80;
location / {
proxy_pass http://app;
proxy_next_upstream error timeout http_502 http_503; # retries; only safe for idempotent requests
proxy_next_upstream_tries 2;
}
}
# HAProxy: HTTP mode, active checks, two choices
backend app
balance random(2) # power of two choices
option httpchk GET /healthz
default-server inter 5s fall 2 rise 3 slowstart 30s
server s1 10.0.0.11:8080 check
server s2 10.0.0.12:8080 check
server s3 10.0.0.13:8080 check
7. The policy in code
Selecting a server is a few lines. Here is power of two choices with a count of in-flight requests per server (pick a language on the tabs; your choice is remembered):
import random
class Pool:
def __init__(self, servers):
self.servers = list(servers)
self.inflight = {s: 0 for s in self.servers}
def pick(self):
if len(self.servers) == 1:
return self.servers[0]
a, b = random.sample(self.servers, 2) # two distinct servers
return a if self.inflight[a] <= self.inflight[b] else b
def start(self):
s = self.pick()
self.inflight[s] += 1
return s
def finish(self, s):
self.inflight[s] -= 1
type Pool struct {
mu sync.Mutex
servers []string
inflight map[string]int
}
func (p *Pool) Start() string {
p.mu.Lock()
defer p.mu.Unlock()
a := p.servers[rand.IntN(len(p.servers))]
b := p.servers[rand.IntN(len(p.servers))] // may equal a; with many servers that is rare
s := a
if p.inflight[b] < p.inflight[a] {
s = b
}
p.inflight[s]++
return s
}
func (p *Pool) Finish(s string) {
p.mu.Lock()
p.inflight[s]--
p.mu.Unlock()
}
class Pool {
constructor(servers) {
this.servers = [...servers];
this.inflight = new Map(this.servers.map((s) => [s, 0]));
}
start() {
const a = this.servers[Math.floor(Math.random() * this.servers.length)];
const b = this.servers[Math.floor(Math.random() * this.servers.length)];
const s = this.inflight.get(b) < this.inflight.get(a) ? b : a;
this.inflight.set(s, this.inflight.get(s) + 1);
return s;
}
finish(s) {
this.inflight.set(s, this.inflight.get(s) - 1);
}
}
public class Pool
{
private readonly string[] _servers;
private readonly Dictionary<string, int> _inflight;
private readonly object _lock = new();
public Pool(IEnumerable<string> servers)
{
_servers = servers.ToArray();
_inflight = _servers.ToDictionary(s => s, _ => 0);
}
public string Start()
{
lock (_lock)
{
var a = _servers[Random.Shared.Next(_servers.Length)];
var b = _servers[Random.Shared.Next(_servers.Length)];
var s = _inflight[b] < _inflight[a] ? b : a;
_inflight[s]++;
return s;
}
}
public void Finish(string s)
{
lock (_lock) { _inflight[s]--; }
}
}
Always call finish in a finally block, or a failed request leaves the count too high forever.
8. Making the balancer itself highly available
- Two balancers, one address: an active and a standby share a virtual IP that moves on failure (VRRP, used by keepalived), or both are active behind equal-cost routing.
- DNS: return several balancer addresses, or use a cloud balancer that is already a managed, multi-zone service. DNS caching means a change takes as long as the record's TTL to reach clients (see what happens when you type a URL).
- Global balancing: DNS or anycast routing sends users to the nearest region, and each region has its own balancers.
- Capacity: the balancer has limits too (connections, bandwidth, TLS handshakes per second). Measure them.
9. Common mistakes
- Balancing by request count with uneven request cost. The simulation shows why least connections or p2c is better.
- A health check that is too strong or too weak. Too strong takes everything out together; too weak (just "port open") keeps a broken server in.
- Retrying non-idempotent requests, so a payment is made twice.
- Retries without a cap or a budget, which turn a partial outage into a total one.
- Keeping state in a single server's memory and then needing sticky sessions forever.
- Forgetting the real client address. Behind a balancer the server sees the balancer's IP. Read
X-Forwarded-Foror the PROXY protocol, and trust it only from your own balancer. - No connection draining, so deploys cut off in-flight requests.
10. Things to mention in an interview
- Say what a balancer gives you: capacity, availability, safe deploys.
- Compare layer 4 and layer 7 and say when you need each.
- Know the policies, and explain why round robin fails when requests differ and why two random choices beat random so much.
- Cover health checks (active and passive), draining, slow start.
- Keep servers stateless; use sticky sessions only when you must.
- Remember the balancer is itself something that must not fail.
Quick recap
- A load balancer spreads requests over servers and removes dead ones.
- Layer 4 is fast and blind; layer 7 can route on the content and retry per request.
- Random and round robin ignore load; least connections and power of two choices look at it and do much better (see the table).
- Health checks should be cheap and should separate "restart me" from "do not send me traffic".
- Retry only idempotent requests and only a few times.
- Stateless servers make everything easier than sticky sessions.
- Make the balancer redundant too.
Sources
- Yossi Azar, Andrei Broder, Anna Karlin and Eli Upfal, Balanced Allocations, SIAM Journal on Computing, 1999. The balls-into-bins result behind two random choices.
- Michael Mitzenmacher, The Power of Two Choices in Randomized Load Balancing, IEEE Transactions on Parallel and Distributed Systems, 2001. The same idea for servers with queues.
- NGINX documentation, HTTP load balancing and ngx_http_upstream_module.
- HAProxy documentation, Configuration manual (the
balancekeyword,option httpchk, and servercheck,inter,fall,rise,slowstart). - Envoy documentation, Load balancing and the least request policy settings.
- IETF, RFC 9110: HTTP Semantics. Which methods are idempotent and so safe to retry.
Practise this topic: Resilience & Scalability Load balancing Networking