all_lessons / system_design / 05 · scaling & load balancing lesson 5 / 19

Horizontal scaling & load balancing

One box is at 80% utilisation and the graph is still climbing. Your next move decides whether the system can ever survive a single dead machine. This lesson is about making that move on purpose.

Where this sits
Lesson 02 gave you the utilisation law: latency explodes as ρ → 1, so you must run each node at a safe ρ (say 0.6–0.7) and keep headroom. This lesson is the mechanism for buying that headroom — adding boxes — and the machinery (load balancer, statelessness, health checks) that makes adding boxes actually work. Lessons 04–06 then deal with the tier you can't make stateless: the data.

1. Scale up vs scale out

When demand outgrows a single server you have exactly two levers. Vertical scaling (scale up) means a bigger box: more cores, more RAM, faster disk. Horizontal scaling (scale out) means more boxes behind a distributor. They are not equivalent, and the difference is the whole lesson.

PropertyVertical (scale up)Horizontal (scale out)
Code changeNone — same process, same assumptionsTier must become stateless + a coordination layer
Cost curveSuper-linear: top-bin CPUs/RAM carry a premium~Linear: commodity boxes, buy N of them
CeilingHard — largest instance that existsSoft — keep adding nodes (until a shared dependency caps you)
Failure domainOne. Box dies → service diesN. Lose a node, the rest serve on
When it shinesStateful single-writer DBs, quick wins, low opsStateless app/web tier, large fleets, HA requirements

Vertical is genuinely the right first move sometimes: it is free engineering-wise and keeps you on one consistent machine (no distributed-systems tax). But it buys time, not safety — a single 96-core box at 50% utilisation is still a single failure domain. The moment availability matters, you are scaling out, because only horizontal scaling adds redundancy. Adding the second box is never about throughput first; it is about surviving the loss of the first box.

The super-linear cost trap
Doubling vCPUs on a cloud instance roughly doubles price up to mid-range, then the top SKUs charge a fat premium for the privilege of the ceiling. Two mid-range boxes usually cost less than one double-size box and give you a spare. Scale-up's hidden bill is paid in availability, not just dollars.

2. Stateless is the magic word

Horizontal scaling only delivers free scaling and free failover if any replica can serve any request. That property has a name: statelessness. A replica is stateless when no per-client state lives on the box — the request carries everything needed, or the state is fetched from a shared store.

Where does the session go, then? Three standard homes:

The instant a replica holds the only copy of a user's session, two things break at once. Failover breaks: that replica dies and the user is stranded (logged out, cart lost). Scaling breaks: the LB can no longer route freely — it must pin that user to that box (a sticky session), so load can't rebalance and a hot user can't be moved off a hot node.

Sticky sessions are a smell
"Just enable session affinity" is the band-aid that hides missing externalised state. It works until a deploy, an autoscale-in, or a crash evicts the box — then every pinned user on it has a bad day simultaneously. The fix is not better stickiness; it's removing the reason to be sticky. (Legitimate stickiness exists — e.g. long-lived WebSocket connections — but treat it as a deliberate exception, not a default.)

Statelessness has a quieter partner that the LB forces you to confront: idempotency. A load balancer (or the client) that times out and retries a request can deliver the same write twice — the first attempt may have succeeded and only the response was lost. A non-idempotent write then double-executes (the card is charged twice, the order placed twice). An idempotent endpoint — one where applying the same request twice has the same effect as applying it once — makes that retry safe. This is why "stateless + idempotent" is the combination that lets you retry freely: stateless means any replica can take the retry, and idempotent means it doesn't matter if the original already ran. See 03 for how idempotency keys make a write safely repeatable, and 12 for why idempotency is the workhorse that turns unreliable, at-least-once delivery into a correct result.

3. The load balancer

Once there are N replicas, something must spread requests across them. That distributor is on the critical path of every request, so it is both essential and a prime single point of failure. You run it redundantly (active-active pair, or a managed LB that is itself a fleet) — an LB that can take the whole site down is worse than no LB.

L4 vs L7

L4 (transport)L7 (application)
Routes byIP + port (TCP/UDP)HTTP path, header, cookie, method
Sees payload?No — protocol-opaqueYes — parses HTTP, can terminate TLS
Cost / throughputCheap, very high pps, low latencyMore CPU per request (parsing, TLS)
Can doFast fan-out, NLB-stylePath routing, canary by header, WAF, retries, gzip
SPOF weightSimple, hard to breakRicher, more logic to go wrong

Globally, before any of that, you have coarse distribution: DNS round-robin hands out rotating A-records (simple, but clients/resolvers cache and TTLs lag failures), and anycast announces one IP from many sites so BGP routes each client to the nearest PoP. These spread load between regions/PoPs; L4/L7 LBs spread it within a region. Most real systems use all of them stacked.

Note the granularity ladder, coarse to fine: anycast/DNS picks a region → L4 LB picks a fleet → L7 LB picks a replica by request content. Each layer is independently redundant; a failure at one layer should never hard-depend on a single instance at the next.

stateless tier (free to scale/fail) ┌──────────────────────────────────┐ clients ──DNS/──► LB (redundant pair) │ anycast │ ├──► replica A ─┐ │ │ ├──► replica B ─┤ │ │ └──► replica C ─┘ │ └────────────────────┼──────────────┘ ▼ ┌──────────────────────────┐ │ shared state store │ ◄─ STATEFUL │ (sessions / cache / DB) │ (lessons 06–08) └──────────────────────────┘

LB algorithms

AlgorithmIdeaTrade-off
Round-robinNext replica in rotationDead simple; ignores actual load & request cost
Weighted RRRR with per-node weightsHandles heterogeneous boxes; weights are static guesses
Least-connectionsSend to fewest open connsGood when request cost varies; conns ≠ load for keep-alive
Least-response-timeFewest conns + lowest latencyAdapts to slow nodes; needs live measurement
Consistent hashinghash(key) → same nodeAffinity for cache locality (lesson 07); uneven without vnodes
Power-of-two-choicesPick 2 at random, take the less loadedNear-optimal balance with O(1) state; avoids the herd
Why not "always pick the least-loaded"?
Because every LB instance sees the same "least-loaded" node and stampedes it before metrics update — you get a thundering herd that oscillates. Power-of-two-choices samples 2 random nodes and picks the better one; this exponentially reduces max load versus pure random, with none of the global-state coordination that exact least-loaded needs. It is the default you reach for when round-robin isn't enough.

4. Health checks & capacity planning

The LB must stop sending traffic to broken nodes. Two depths:

Re-derive the N+k formula
You serve peak after losing k nodes iff peakQPS / (N − k) ≤ safeCap. Solve for N: N ≥ peakQPS / safeCap + k. The first term is the bare minimum to carry peak; the + k is literally the spare nodes. So "N+2" is not jargon — it's the rearranged inequality with k = 2.

N+1 / N+2 and cascading collapse

The point of running multiple replicas is to survive losing some. N+k provisioning means you carry k spare replicas beyond the bare minimum N needed to serve peak at safe utilisation, so you can lose k nodes and still meet SLA. Run exactly N (k = 0) and the first failure is fatal in a specific, vicious way:

The cascade
Node fails → its traffic is redistributed to survivors → survivors now exceed safe ρ → from lesson 02, latency spikes → deep health checks start timing out → the LB pulls those "failing" nodes → even fewer survivors absorb even more load → the entire fleet flaps out within seconds. Zero headroom doesn't degrade gracefully; it collapses. This is why "we're at 99% CPU but it's fine" is a lie waiting to happen.

Autoscaling adds and removes nodes automatically. Reactive (scale on CPU/QPS thresholds) is simple but lags — by the time the metric crosses, you're already behind, and cold-start/warm-up (JIT, connection pools, cache fill) means new capacity isn't useful for tens of seconds. Predictive/scheduled scaling pre-warms ahead of known peaks. Always scale in conservatively: yanking capacity right before a spike re-creates the exact zero-headroom cascade above.

A subtle trap: autoscaling on CPU assumes CPU is your bottleneck. If a downstream DB or connection-pool limit is the real cap, adding app replicas just multiplies the pressure on the shared dependency and accelerates its collapse. Always scale on the metric that reflects the actual constraint, and verify the next tier can absorb the fan-out before you let the fleet grow.

Worked example — sizing for N+2
Peak = 12,000 req/s. Each replica safely serves 2,000 req/s at target ρ = 0.65.
Bare minimum N = ⌈12000 / 2000⌉ = 6 replicas (at 100% of safe capacity — zero headroom, the cascade waits).
After losing 1 of 6: per-node load = 12000 / 5 = 2,400 req/s > 2,000 → overload. So 6 isn't even N+1.
For N+2 (survive 2 failures): need 12000 / (M − 2) ≤ 2000 → M − 2 ≥ 6 → M = 8 replicas.
Check: lose 2 of 8 → 12000 / 6 = 2,000 = safe cap exactly. Fleet utilisation at full strength = 12000 / (8 × 2000) = 75% of safe capacity. That 25% slack is the price of surviving two dead boxes — and it is cheap insurance.

5. Capacity & failure-headroom calculator

Set your peak load and per-replica safe capacity, then see how many nodes you can actually afford to lose. The "+k you really have" is the number that matters in the postmortem.

Failure-headroom calculator
Per-replica safe capacity = req/s a node serves at your target ρ (from lesson 02), not its absolute max.
Per-replica load (now)
Fleet utilisation
Nodes you can lose (+k)
Utilisation after −1 node
Verdict

Interview prompts you should be ready for

  1. "Your service is at 80% CPU on one box. Walk me through your next move." (senior answer: confirm it's the app tier not the DB; if availability matters, scale out not up, because only adding boxes adds a second failure domain — a bigger box is still one box. First make the tier stateless: push session to a shared store or signed token. Then put a redundant LB in front. Size for N+2 against peak at safe ρ.)
  2. "What does 'stateless' actually buy you, concretely?" (senior answer: any replica serves any request, so the LB routes freely and silently drops a dead replica with zero user impact — free failover and free scaling. The cost is an extra network hop to the shared state store and the need to make that store HA, which is what lessons 06–08 are about.)
  3. "L4 or L7 load balancer here — why?" (senior answer: L7 if I need path/header routing, TLS termination, canary, or a WAF — it sees HTTP. L4 if I just need raw fan-out at very high packet rates with minimal latency and the routing logic is trivial. Many stacks layer L4 in front of L7. State the CPU cost of L7 parsing as the trade.)
  4. "Round-robin is balancing poorly — requests have wildly different costs. What do you switch to?" (senior answer: least-connections or least-response-time so expensive in-flight requests steer new ones away; or power-of-two-choices for near-optimal balance with O(1) state. Avoid exact 'least-loaded' — every LB stampedes the same node before metrics refresh.)
  5. "Why not just enable sticky sessions and move on?" (senior answer: stickiness pins a user to a box, so you lose free failover — that box dies and every pinned user is stranded — and load can't rebalance. It's masking missing externalised state. Acceptable only as a deliberate exception, e.g. long-lived WebSockets, never as the default fix.)
  6. "You run exactly enough replicas for peak and one dies. What happens?" (senior answer: its traffic redistributes, survivors cross safe ρ, latency spikes per the utilisation law, deep health checks time out, the LB pulls them, and the fleet cascades to total collapse within seconds. That's why I provision N+1/N+2 and keep fleet utilisation at ~70–80% of safe capacity.)
  7. "Shallow vs deep health checks — which and how often?" (senior answer: shallow/liveness frequently to catch dead processes cheaply; deep/readiness less often to catch up-but-broken nodes — wedged pools, OOM zombies, 200-but-slow. Guard the deep check so a shared-dependency hiccup can't pull the entire fleet out of rotation at once.)
  8. "Autoscaling didn't save us during a traffic spike. Why?" (senior answer: reactive scaling lags — the threshold trips after you're already behind, and new nodes have cold-start/warm-up before they're useful, so you eat a few minutes of degradation. Fix with predictive/scheduled pre-warming for known peaks and conservative scale-in so you never strip headroom right before a surge.)
Takeaway
Scale up for simplicity and quick wins; scale out the moment availability matters, because only more boxes add a second failure domain. Horizontal scaling is unlocked by one property — statelessness — which is what makes the LB free to route and free to drop dead nodes. Pick the LB layer (L4/L7) and algorithm (power-of-two-choices is a strong default) for your routing needs, make it redundant, and back it with health checks deep enough to catch up-but-broken nodes. Above all, provision N+2 against peak at safe ρ: running exactly N doesn't degrade — it cascades.