Rate limiting
Scope: limit_req — nginx's built-in throttle —
what the numbers mean, what a tripped limit looks like,
and the ordering trap that can leave you unthrottled
while believing otherwise.
The two halves
A zone defines the budget; a directive spends it.
http {
limit_req_zone $binary_remote_addr zone=labzone:10m rate=5r/s;
}
...
location /limited/ {
limit_req zone=labzone burst=2 nodelay;
try_files /hello.txt =404;
}
limit_req_zone counts requests per
$binary_remote_addr (one budget per client IP), kept
in a 10 MB shared-memory zone, refilling at 5 requests
per second. limit_req in a location applies that
budget there. burst=2 lets a client get 2 requests
ahead of the refill without penalty; nodelay makes
excess requests fail at once instead of queueing.
A tripped limit, captured
Twelve rapid requests from one client:
$ for i in $(seq 12); do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:8080/limited/; done; echo
200 200 200 503 503 503 503 503 503 503 503 503
$ sleep 2; curl -s http://127.0.0.1:8080/limited/
main root: plain file hello.txt
Three through (roughly the burst plus a refill or two
during the loop), then 503 — nginx's default answer
to "over the limit" (limit_req_status can change it
to 429, the more truthful code). Wait two seconds and
the bucket has room again. The error log records each
refusal once:
2026/10/20 20:46:28 [error] 14159#14159: *91 limiting requests, excess: 2.580 by zone "labzone", client: 127.0.0.1, server: localhost, request: "GET /limited/ HTTP/1.1", host: "127.0.0.1:8080"
The limit is per-IP: behind another proxy, every visitor shares the proxy's budget (another reason chapter 7's headers matter), and on this lab's single loopback client the whole Internet is one address.
The trap: return answers first
The lab's first version of this location ended in
return 200 "ok\n"; instead of serving a file — and
twelve rapid requests came back all 200. No error,
no log line, no limit.
The reason is phase order. nginx processes a request in
stages, and limit_req is enforced late (the
preaccess phase), while return answers early (the
rewrite phase). A location whose response is a
return never reaches the phase where the limit lives.
Nothing warns you: the config tests fine and the limit
reads as correct.
The general lesson outlasts the feature: a directive can be present, spelled right, and in scope — and still never execute, because something earlier in the request's path answered first. When a behaviour seems set but never happens (in nginx, in firewalls, in shell startup files), suspect ordering before spelling.
Check: after configuring any limit, trip it — from
the shell, like the loop above — and watch for the 503s
and the limiting requests line in the error log. A
limit you haven't seen refuse anything is a limit you
don't yet have.