Nginx Round-Robin Load Balancing: Testing the Default Strategy and Its Boundaries
When Nginx acts as a reverse proxy, how to split traffic across multiple backends is the first question to answer. Round-Robin is the most basic answer: requests are handed to backends one by one in order, and when a cycle finishes, it starts over. It’s also Nginx’s default strategy — when the upstream block contains no load-balancing directive at all, round-robin is what you get. It’s simple enough to be taken for granted and never looked at twice. But under what distribution does round-robin actually stay even, and where does traffic go when a backend dies? Worth testing before trusting.
This post is organized as hands-on configuration: first the test environment, then measured runs of three key behaviors — the round-robin sequence, weighted distribution, and failover on backend outage — and finally a set of easily-miswritten config comparisons. All output is from actual runs on nginx/1.31.5.
Test Environment
Every example is reproducible in a single container: nginx/1.31.5 (docker image nginx:alpine), with three server blocks listening on 8081/8082/8083 simulating three backends (each responding with backend1/2/3), and port 80 reverse-proxying to the upstream; access_log records $upstream_addr to see where each request actually landed. When judging “who got the traffic,” guessing is useless — this variable gives a definitive answer, and it works for outage debugging too. The experiment pins worker_processes to 1; the reason is the observation trap in Test 1.
When Round-Robin Fits
Let’s set the boundaries first, then look at the tests:
- Similar backend configurations: when machines perform about the same, round-robin splits naturally with zero tuning.
- Stateless requests: when it doesn’t matter which machine a request lands on (sessions aren’t pinned to a machine), round-robin is the most natural choice.
- Light to moderate traffic: small apps don’t need fancier strategies — the default is a good answer.
Conversely, with wildly uneven backend performance or a need for session persistence, round-robin is a poor fit — the latter is covered in the notes at the end.
Test 1: The Round-Robin Sequence
Start with the most basic even distribution:
1 | upstream backend { |
Six requests in a row — exactly two full cycles — with access_log’s $upstream_addr recording the real routing:

Strictly sequential rotation; after one full cycle it starts over from the top.
Production configs usually also carry a set of header passes in the location so backends get real client information — don’t skip them:
1 | location / { |
X-Forwarded-For uses $proxy_add_x_forwarded_for to append rather than overwrite, so under multi-level proxies the backend can see the full chain; without passing Host, the backend application always sees the upstream name instead of the domain the user actually visited.
One observation trap I genuinely hit during testing deserves a warning: the experiment originally ran under the default config (worker_processes auto), and 6 of 9 requests landed on 8081 — the round-robin pointer is maintained independently per worker. When a small number of short-lived requests spread across multiple workers, most requests happen to be each worker’s “first request,” and they all start from the first backend in the list. When a load test shows “uneven” distribution, check worker count and request volume before condemning round-robin; with workers pinned to 1, this experiment’s sequence was perfectly strict.
Test 2: weight — Leveling Out Uneven Machines
When machine performance differs, weight the strong ones:
1 | upstream backend { |
Ten requests in a row, measured:

8082 received 5 requests, 8081 and 8083 got 2-3 each; the overall ratio matched 2:1:1. Note the ordering is not “8082 twice in a row, then the others” — Nginx uses a smooth weighted algorithm to interleave the high-weight machine’s requests, avoiding momentary pressure spikes. So don’t write assertion scripts against a fixed sequence — verify by ratio.
While we’re here, let’s meet the neighboring strategies that live in the upstream block: least_conn picks the backend with the fewest current connections, good for services with highly variable request durations; ip_hash maps each client IP to a fixed backend, the laziest workable solution for session persistence; random two picks two machines at random and takes the one with fewer connections (the least_time variant requires the commercial edition). They all replace the default round-robin inside the upstream block and stack with server-level parameters like weight and max_fails.
Test 3: A Backend Dies — Where Does the Traffic Go?
Stop 8083’s listener to simulate an outage (it’s still in the upstream list in the config), and with max_fails=3 fail_timeout=30s fire 12 requests:
1 | upstream backend { |
Measured results:

The client got nothing but 200s — failover is completely transparent to users. Of the 12 requests, 8083 was actually attempted 3 times (requests 3, 5, and 8, matching three connect() failed (111: Connection refused) entries in error_log), and $upstream_addr recorded two segments like 8083, 8081: try 8083 first, fail, immediately hand off to the next machine to answer. After 3 failures filled max_fails=3, 8083 was marked down: the last 4 requests never touched it, and it wasn’t retried for 30 seconds. When the window expired, Nginx probed with a live request (indeed observed in the experiment), and on another failure the timer restarted — once the backend recovers, it automatically rejoins the rotation. That’s the point of the max_fails/fail_timeout pair: passive health checking, using failure counts instead of active probes — good enough for small deployments.
A Comparison Set: Configs That Are Easy to Get Wrong
Mistake one: writing round-robin as if it were a directive
1 | # Wrong: nginx has no round_robin directive |
Measured: nginx -t fails immediately with unknown directive "round_robin" — round-robin is the default behavior, not a directive; you won’t find a syntax entry for it in the official docs either.
Mistake two: assuming uneven observation means round-robin is broken — that’s the multi-worker trap from Test 1 above. Pin worker_processes 1 or increase the request volume before drawing conclusions.
Notes and Cautions
- Session persistence: for applications that need sessions pinned to one machine, round-robin is the wrong choice; consider
ip_hashor externalizing session state to Redis. - Weight changes require a reload: weight changes the request ratio and takes effect only after reloading the config; it is not a runtime dynamic adjustment. Watch for momentary jitter when adjusting production weights.
- Slow-machine protection: when performance gaps are large, level with weight, or throttle slow machines with
max_connsto keep a slow node from dragging out the overall P99. - keepalive is not automatic: for
keepalive 32in an upstream to actually work, you must add bothproxy_http_version 1.1;andproxy_set_header Connection "";, clearing the default HTTP/1.0 short-connection behavior (explicitly required by the official docs; reuse performance not separately measured in this post). Note it’s a pool of idle per-worker connections to the backend, not a concurrency cap. - Backup machines:
server ... backup;takes no traffic normally and steps in only when all non-backup nodes are down (or removed). It and max_fails’s passive ejection are two complementary layers — the former decides “who takes over,” the latter decides “when a bad node leaves.”
Summary
Stringing the three tests together: round-robin itself is strictly sequential (barring the multi-worker observation artifact), weight levels out machine differences by ratio, and after an outage max_fails/fail_timeout passively ejects the bad node while failover stays invisible to users. The right posture for the default strategy: use the default config with confidence; when distribution looks uneven, check your observation method first; wildly unequal performance and session stickiness are its real boundaries. Before the next Nginx goes live, curl through these three behaviors once each — more reassuring than any documentation.
This article was rebuilt from the author’s CSDN blog posts published between 2020 and 2024, originally published on CSDN.