Lesson 2 measured one server's hard ceiling: the knee sits around 4 concurrent connections, and with a CPU-bound handler throughput stands still at 200 req/s from a single connection onward. However perfect the code, one process is still one process — and one point of failure.

This lesson puts nginx in front, multiplies the app into three replicas, then measures again. Two results turned out counter-intuitive enough that I re-ran them several times to be sure: a distribution algorithm whose chart looks completely balanced yet whose p95 is 340x worse, and graceful shutdown — which every piece of documentation recommends — turns out to make no measurable difference in the single most common case.

ℹ️ The machine every figure in this lesson was measured on
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Containers run native arm64 (Node v22.23.2). Each app replica is limited to 1 CPU / 256 MB, nginx is 1.27-alpine, and the load generator gets 2 CPUs. Every experiment was repeated at least 3 times and the lesson reports the range, not a single run treated as the conclusion.

3.1 Scaling up and scaling out

Scaling up is giving the machine more power: more CPU, more RAM. Scaling out is adding more machines. The two directions are not equivalent, and picking the wrong one is one of the most expensive mistakes you can make.

Scaling up wins on simplicity: no distributed thinking required, no consistency lost, no extra network hop. But it has three hard limits:

  • A physical ceiling. There is no such thing as an infinitely powerful machine.
  • Non-linear pricing. A machine with double the performance usually costs more than double, especially at the high end.
  • Still a single point of failure. This is the most serious limit and cannot be bought away — a dead 128-core machine still means a dead system.

Scaling out removes all three, but it comes with one non-negotiable prerequisite: the application must be stateless. Concretely: the process must not hold anything in RAM that a later request needs to read back — session data, a local cache, a counter, a half-finished computation.

🕳️ Pitfall: scaling out an app that still keeps state in RAM
This is one of the most annoying bug classes in the trade, because it does not crash the system — it is only wrong sometimes. A user logs in on replica 1, the next request lands on replica 2 and gets treated as not logged in. With 3 replicas the chance of landing on the "right" one is 1/3, so the bug fires roughly 2/3 of the time — but since you test on a dev machine with one replica, it never reproduces there.

The tell: the bug disappears when you scale back down to 1 replica. If you see that symptom, do not go hunting for a race condition — go hunting for local state.

3.2 L4 and L7: what a load balancer can "see"

The core difference between the two layers is not speed — it is how much information the load balancer can read, and the price paid to read it.

L4 vs L7 — the same request, two levels of "visibility" L4 — transport layer (TCP) CAN READ: source / destination IP · port CANNOT READ: URL path · headers · cookies → cheap, fast, no TLS decryption L7 — application layer (HTTP) CAN READ: everything L4 has, plus: URL · headers · cookies · method → path-based routing, A/B testing, TLS termination → must decrypt TLS: costs CPU, becomes a tier you have to scale separately Same packet — which part falls inside each type's "field of view": TCP header 10.0.0.5:443 → app:3000 HTTP payload (encrypted if it is HTTPS) GET /api/orders/42 · Host: shop.vn · Cookie: sid=abc · Authorization: Bearer … L4 only sees up to here L7 sees everything — but only after decrypting TLS This series' lab runs nginx in L7 mode, because we need to route by path and log which replica each request reached.
There is no absolute "better" choice: L4 for raw throughput, L7 for the ability to make decisions.
🕳️ Pitfall: choosing L7 for everything, then being surprised the LB becomes the bottleneck
L7 has to decrypt TLS to read HTTP. At high volume, that cost turns the load balancer itself into a component that needs scaling — and it is the one component every single request must pass through. If you do not need content-based routing (for example, you are just splitting load across one homogeneous pool), L4 is both cheaper and one fewer thing to worry about.

3.3 Distribution algorithms — and the measurement that made me re-run it

The four most common algorithms:

Algorithm How it picks a replica Weakness
Round Robin Cycles through in order Does not know which replica is busy — splits the count evenly, not the load
Least Connections The replica with the fewest open connections Needs to track state; "fewer connections" is not always "less load"
Random Picks at random High variance, prone to random clustering
Random of two choices Samples 2 at random, picks the less busy one Almost none — as cheap as random with quality close to least-conn

When every request costs the same and every replica is equally healthy, all four give near-identical results. The difference only shows up when there is skew — and in practice there is always skew: one replica just had a GC pause, one is running on a noisier host, one just restarted so its cache is still cold.

Experiment: one degraded replica

This is a deterministic and very real scenario: three replicas, one of which has its event loop occupied (I pump load into /slow-sync?ms=30 directly on app3, bypassing the load balancer). Then I measure /fast through the load balancer. Repeated 3 times per algorithm:

Algorithm Throughput p50 p95 p99
Round Robin 147 req/s 0.50 ms 119.64 ms 149.70 ms
215 req/s 0.43 ms 119.42 ms 119.79 ms
145 req/s 0.41 ms 120.91 ms 149.49 ms
Least Connections 6,511 req/s 0.23 ms 0.35 ms 2.64 ms
5,892 req/s 0.23 ms 0.35 ms 59.11 ms
8,883 req/s 0.23 ms 0.35 ms 0.64 ms

Slow down reading this table — it holds three things worth noticing:

  • Throughput differs by roughly 40x (~170 req/s vs ~7,100 req/s). Same hardware, same source code, exactly one config line different.
  • p95 differs by roughly 340x (~120 ms vs 0.35 ms). With round robin, about one in three requests lands on the blocked replica and has to wait its turn.
  • But p50 is nearly identical (0.4–0.5 ms vs 0.23 ms). This is the dangerous part: if your dashboard shows median latency, this disaster is completely invisible.
The same degraded replica — two different reactions Round Robin — splits the COUNT evenly app1 11 requests · healthy · 0.4 ms app2 10 requests · healthy · 0.4 ms app3 9 requests · CURRENTLY BLOCKED · ~120 ms The "requests per node" chart looks perfectly balanced (11 / 10 / 9) — nothing looks alarming. But 1/3 of users wait ~120 ms. Result: 147 req/s, p95 = 119.6 ms. Least Connections — splits by ACTUAL WORK app1 receives most of the load · 0.23 ms app2 receives most of the load · 0.23 ms app3 AVOIDED because it is holding many connections The chart looks SKEWED — but the result: 6,511 req/s, p95 = 0.35 ms. Skewed is correct here.
The requests-per-node counts are real, measured through the load balancer while app3 is blocked.
🕳️ Pitfall: using "requests per node" to judge load balance
I measured exactly this: with round robin and app3 completely blocked, the distribution is still 11 / 10 / 9 across 30 requests — nearly perfect balance. A dashboard showing "requests per node" would show three equal columns, all green, while throughput has collapsed 40x.

Round robin always splits the count evenly — that is its definition. So that metric carries no information about load balance at all. Look at utilization and latency per replica instead. And conversely: when least-conn is working correctly, the count chart will look skewed — do not "fix" it.

Implementing the four algorithms

Just a few lines per algorithm. This is the real logic running in the series' simulator (sysdesign-sim-engine.js), so you can cross-check the simulation's behaviour against the lab:

four_lb_algorithms.js
// `alive` = list of replicas still alive. Each replica has: busy (in-flight count), queueDepth.
function pickReplica(alive, policy, rng) {
  if (alive.length === 0) return null;      // whole tier is dead => must drop, cannot hang
  if (alive.length === 1) return alive[0];

  switch (policy) {
    case 'round-robin': {
      const r = alive[this.rrCursor % alive.length];
      this.rrCursor++;
      return r;                              // does NOT read replica state at all
    }

    case 'least-connections': {
      let best = alive[0];
      for (const r of alive) {
        if (r.busy + r.queueDepth < best.busy + best.queueDepth) best = r;
      }
      return best;                           // reads state => avoids the congested node
    }

    case 'random':
      return alive[Math.floor(rng.next() * alive.length)];

    case 'random-two-choices': {
      // "Power of two choices": sample 2 replicas at random, keep the less busy one.
      // Cheap like random, but balance quality is close to least-connections.
      const a = alive[Math.floor(rng.next() * alive.length)];
      const b = alive[Math.floor(rng.next() * alive.length)];
      return a.busy + a.queueDepth <= b.busy + b.queueDepth ? a : b;
    }
  }
}

The four switch branches above are the entire difference between the algorithms: the last two (least-connections, random-two-choices) read replica state before picking, the first two (round-robin, random) have no idea which replica is busy at all. In nginx it is just one line inside the upstream block (the block declaring the group of backend servers nginx distributes load to):

nginx/lb.conf
upstream app_pool {
    # nginx defaults to round robin (nothing to declare for it).
    # Uncomment the line below to switch to least connections, then RE-MEASURE:
    least_conn;

    server app1:3000 max_fails=2 fail_timeout=5s;
    server app2:3000 max_fails=2 fail_timeout=5s;
    server app3:3000 max_fails=2 fail_timeout=5s;

    # Persistent connections to upstream: without this, nginx opens a new TCP
    # connection for EVERY request, and most of the latency you measure will
    # just be the internal TCP handshake cost.
    keepalive 64;
}
🕳️ Lab pitfall: nginx -s reload reading a truncated file
This actually happened while I was switching algorithms to measure. Editing the config file in place (with sed -i) while the file is bind-mounted into the container makes nginx read a half-written state:

nginx: [emerg] pread() returned only 2830 bytes instead of 2832

The dangerous part: the reload fails but nginx keeps running on the old config. I measured one whole batch of "after switching algorithms" numbers that was, in fact, still round robin — and had to throw it away.

The correct approach: write the file atomically (write to a temp file, then mv it) or restart the container, and always confirm the config actually took effect instead of trusting that it changed:
docker compose exec lb grep -c '^ *least_conn;' /etc/nginx/conf.d/default.conf

3.4 Health checks, failover and graceful shutdown

A load balancer is only useful if it knows which replicas are alive. There are two ways to know, and open-source nginx only has one:

  • Active: the load balancer periodically calls /health. Early detection, pulling a node out before any request is affected. This is an nginx Plus feature — not present in the open-source build.
  • Passive: counts real failures. max_fails=2 fail_timeout=5s means after 2 failures within 5 seconds, that node is temporarily removed. The important consequence: the first few requests after a node dies still have to fail — that is the price of detection.

But knowing which replica died is only half the story. The other half is graceful shutdown: when that replica stops on purpose (a deploy, a scale-down, an operator command), it should not exit immediately. The right sequence: mark itself unhealthy so the load balancer stops sending it new requests, then drain — wait for in-flight requests to finish — and only then exit the process. The Unix signal that usually triggers this is SIGTERM (a request to stop gracefully, giving the process a chance to clean up before exiting), as opposed to SIGKILL (an immediate kill, no chance to clean up). docker compose stop sends SIGTERM first, and only escalates to SIGKILL if the process has not exited after a grace period.

Experiment: stopping a replica under load

Running load against /fast through the load balancer with 8 connections, and at second 5, docker compose stop app3. I expected to see a burst of 502 errors. The actual result was completely different:

Metric With graceful shutdown Without graceful shutdown
Errors the client sees 0 0
Status codes all 200 all 200
p99 0.92 ms 0.90 ms
Max latency 2,011 ms 2,011 ms
Requests nginx had to retry on another upstream 27 31
Requests that took ≥ 1 second 29 27

Two findings, and both went against what I predicted before measuring.

First: zero errors. The cause sits right in the proxy_next_upstream config — nginx automatically retries the request on another upstream. nginx's log shows each case clearly:

nginx log — a retried request
172.21.0.6 "GET /fast HTTP/1.1" status=200 \
  to=172.21.0.2:3000, 172.21.0.4:3000 \
  ut=2.001, 0.000  rt=2.001  tries=504, 200

# How to read this line:
#   to=   two upstreams => already retried
#   ut=   first upstream took 2.001s (proxy_connect_timeout expired), second took ~0s
#   tries=504, 200  => first attempt timed out, second attempt succeeded
#   status=200      => CLIENT NEVER SAW AN ERROR, only extra latency

This is good news and bad news at once. Good news: the retry saves availability. Bad news: it turns lost availability into lost latency, and the size of that loss is exactly the proxy_connect_timeout you configured — 2 seconds here. If you only watch the error rate, you would conclude "the deploy went completely smoothly" while ~30 users just waited 2 seconds.

Second: graceful shutdown makes no measurable difference. 27 versus 31 retried requests is within noise. This sounds like it contradicts every piece of operations documentation, but the reason is simple: /fast finishes in 0.2 ms, so at the moment SIGTERM arrives there is almost never an in-flight request left to drain. The app's log confirms it: "marking unhealthy, waiting for 0 in-flight requests..."

So when does graceful shutdown actually help? Re-measuring with a long request

Repeating the exact same experiment but switching to /slow-async?ms=500 — a request long enough that there are always a few in flight when the node is stopped:

Metric (500 ms request) With graceful shutdown Without graceful shutdown
Errors the client sees 0 0
Max latency 860.78 ms 2,514.83 ms
App log when SIGTERM arrives "waiting for 5 in-flight requests..." → drain complete → exit "exiting immediately, in-flight requests cut off"

Now the difference is clear: 861 ms vs 2,515 ms — the tail latency is roughly 2.9x worse. Five in-flight requests get cut off mid-flight; nginx has to retry them on a different upstream, and most of that cost is the 2-second wait for proxy_connect_timeout to expire before retrying, plus the time to redo the unfinished work.

Stopping a replica while it serves 500 ms requests With graceful shutdown — max 861 ms SIGTERM 5 in-flight requests are RUN TO COMPLETION before exit Sequence: mark unhealthy → wait for drain → exit. No request is cut off, none needs a retry. Without graceful shutdown — max 2,515 ms SIGTERM → exits immediately CUT OFF mid-flight nginx waits out proxy_connect_timeout = 2 s retried, 500 ms Most of the cost is the 2 s wait for proxy_connect_timeout, then redoing the unfinished work — matching the measured max latency: 2,515 ms. The client still gets a 200 — so if you only watch the error rate, you see nothing at all.
Both maximum-latency figures are real measurements from the lab.
🔬 Going deeper: graceful shutdown alone is not enough
The app in this lab does all three steps correctly: mark unhealthy → wait for drain → exit. But open-source nginx never asks /health at all, so nobody is listening for "marking unhealthy". That is exactly why ~30 requests still pay the 2-second price.

The correct deploy sequence is: pull the node out of the load balancer first (through the LB's API, or by waiting out a full active health-check cycle), only then send SIGTERM, then drain, then exit. Skip the first step and graceful shutdown only solves half the problem.

This is also why timeouts are one of the most important parameters in a distributed system — Lesson 17 comes back to this exact 2-second number.
🕳️ Pitfall: a health check that only checks the TCP port
Many configurations only check "is port 3000 open". A Node process with a blocked event loop still has the port open, still completes the TCP handshake — so it still counts as healthy, at the exact moment it is serving nobody at all. That is exactly app3 in the section 3.3 experiment: alive by every TCP criterion, yet with a p95 of 120 ms.

/health has to actually execute a bit of logic, and better still, check its essential dependencies too. But do not overdo it: if /health fails just because the database is slow, the entire pool gets pulled out at once and you have caused a full outage yourself.

3.5 Sticky sessions and the price of local state

When the app keeps sessions in RAM, the fastest fix is forcing every user back to the exact same replica every time — a sticky session. In nginx that is just one line: ip_hash;. It works, and that is exactly the problem.

Consequence Why it happens
Load imbalance Users do not generate equal load; one replica can end up with all the heavy users
Sessions lost when a replica dies State only lives in that replica's RAM — it dies, it's gone, the user gets logged out
Blocks autoscaling Adding replicas does nothing for already-pinned users; removing one loses sessions
Every deploy causes disruption Every replica restart affects every user pinned to it
Breaks under NAT ip_hash pins by IP, so an entire company behind NAT gets funneled onto one replica

The correct fix is moving sessions out to a shared store — Redis, or a signed token so nothing needs to be stored at all. At that point the app is genuinely stateless and every problem above disappears. Lesson 5 builds exactly that layer.

🕳️ Pitfall: using sticky sessions to paper over a design flaw
Sticky sessions are a legitimate tool in a few narrow situations (a WebSocket with connection state, say, or a hot local cache where losing it only means slower, not wrong). But when it gets switched on to avoid fixing local state, the system runs fine today and locks away the ability to scale later. The cost does not show up when you flip the switch — it shows up six months later, when you need to autoscale and discover you cannot.

Reproduce the measurements yourself

terminal
# 1) Bring up nginx + 3 replicas
cd blog/sysdesign/sysdesign-lab
docker compose --profile lb up -d
sleep 10                      # IMPORTANT: let all 3 replicas finish warming up

# 2) Check the distribution (round robin => counts should be roughly equal)
for i in $(seq 1 30); do curl -s http://localhost:8080/whoami; echo; done \
  | grep -o '"instance":"app[0-9]"' | sort | uniq -c

# 3) DEGRADE exactly one replica: hit app3 directly, bypassing the LB
docker compose run --rm -d --name hog loadgen \
  loadgen.js --url "http://app3:3000/slow-sync?ms=30" -c 3 -d 25 -w 0

# 4) Measure /fast through the LB while app3 is blocked
docker compose run --rm loadgen \
  loadgen.js --url http://lb:8080/fast -c 6 -d 10 -w 3

# 5) Switch to least_conn, then RE-MEASURE. Write the file atomically, not with sed -i:
sed 's/^    # least_conn;/    least_conn;/' nginx/lb.conf > /tmp/lb.new \
  && mv /tmp/lb.new nginx/lb.conf
docker compose restart lb
# CONFIRM the config actually took effect before trusting the next measurement:
docker compose exec lb grep -c '^ *least_conn;' /etc/nginx/conf.d/default.conf

# 6) Count requests nginx retried, and requests that took >= 1 second
docker compose logs --no-log-prefix lb | grep -c 'to=[^ ]*,'
docker compose logs --no-log-prefix lb | grep -oE 'rt=[0-9.]+' \
  | awk -F= '$2>=1{n++} END{print n+0}'

Three questions to answer for yourself after running this: is the count distribution balanced (it should be), how far apart is p95 between the two algorithms, and does p50 reflect the problem at all (it should not).

Comparing against the simulation

The Traffic Lab below lets you switch distribution algorithms and kill a replica right on the page. Pick the "2. LB + multiple app servers" topology, set the load high enough, then click "Kill 1 app server" to watch the queue pile onto the remaining replicas and utilization spike.

Three replicas now — but what are they fighting over?

We removed the single point of failure and multiplied throughput. But notice one thing running through this entire lesson: all three replicas read from the exact same data source. Scaling the stateless tier is the easy part — it only pushes the bottleneck one layer down.

Lesson 4 extends nginx from a load balancer into an API gateway — routing by path, TLS termination, and one specific security pitfall: why you must never trust the X-Forwarded-For header a client sends you.

📖 Further reading

Download the lab source

The L7 nginx config used in this lesson (with to=$upstream_addr logging so you can count the distribution yourself) and the simulator core containing the implementation of all four distribution algorithms:

Download lb.conf Download sysdesign-sim-engine.js

Related lessons in this series

Lesson 2: Building the Lab & Measuring One Server's Limits Lesson 4: Reverse Proxy & API Gateway Back to the System Design roadmap

Comments