Skip to content

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.