NGINX Rate Limiting That Actually Protects Your Backend: limit_req, Bursts, and the 429 Fix
NGINX's limit_req module smooths abusive traffic with a leaky bucket — but the defaults (503 rejections, no burst, IP keys behind a load balancer) are subtly wrong for most APIs. Here's a config that works and how to verify it.
10 Feb 2026, 20:58 UTC

Your API works fine until one client — a runaway script, a misconfigured retry loop, a scraper — starts hammering an endpoint that fans out into expensive database queries. The backend melts, and every other client pays for it. NGINX's limit_req module exists for exactly this moment, and it takes about five lines of configuration to get right. The catch is that the defaults are subtly wrong for most APIs, and the burst semantics confuse almost everyone the first time.
The leaky bucket, in plain terms
NGINX rate limiting (from ngx_http_limit_req_module, compiled into standard builds) models traffic as a leaky bucket. Requests pour in at whatever rate clients send them; the bucket drains at the fixed rate you configure, such as rate=10r/s. Requests that arrive while the bucket has capacity pass through. Requests that arrive faster than the drain rate either wait in a queue (if you allow a burst) or get rejected.
The key insight: this is traffic smoothing, not a hard cap. A client sending 1,000 requests in a second against a 10 r/s limit doesn't get 10 served and 990 dropped by default — it gets rejected immediately unless you define a burst queue. That distinction drives every configuration decision below.
A working configuration
Two directives do the work. limit_req_zone goes in the http context and defines a shared memory zone: what to count by, how much state to keep, and the rate. limit_req goes in a server or location block and enforces it.
http {\n # 10 MB of state, keyed on client IP, 10 requests/sec per key\n limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;\n\n server {\n location /api/ {\n limit_req zone=api burst=20 nodelay;\n limit_req_status 429;\n proxy_pass http://backend;\n }\n }\n}\nThree details in that example matter more than they look:
$binary_remote_addrinstead of$remote_addr. The binary form of the address is more compact, so the same memory tracks more clients. As a rough order of magnitude, 1 MB of zone state covers on the order of 16,000 client IPs — so a 10 MB zone handles a large client population comfortably. Verify against your version's docs if you're sizing tightly.burst=20 nodelay. Withoutburst, anything above 10 r/s is rejected instantly. Withburst=20alone, up to 20 excess requests are queued and released at the drain rate — which means a client bursting 30 requests sees the last ones delayed by up to two seconds. Addingnodelayserves queued requests immediately while still marking the bucket as occupied, so short spikes pass through fast but the sustained rate stays capped. For most interactive APIs,burst=20 nodelayis the behavior people actually want.limit_req_status 429. The default rejection status is 503, which tells clients \"the server is broken\" and often triggers aggressive retries. 429 (Too Many Requests) is semantically correct, and well-behaved HTTP clients back off when they see it. This one line prevents a rate limiter from accidentally making the stampede worse.
Verify it before you trust it
Run these on the NGINX host itself, with sufficient privileges to read the config and reload the service:
# Confirm the module is compiled in (look for ngx_http_limit_req_module)\nnginx -V 2>&1 | tr ' ' '\\n' | grep limit_req\n\n# Validate syntax before touching the running config\nsudo nginx -t\n\n# Reload only after -t passes\nsudo systemctl reload nginx\nThen generate load from a client machine — a simple for i in $(seq 1 50); do curl -s -o /dev/null -w '%{http_code}\\n' https://staging.example.com/api/; done works, or use hey/wrk for concurrency. You should see a run of 200s followed by 429s once the burst is exhausted, with 200s returning as the bucket drains. Also watch the error log: NGINX records limiting events there at the level set by limit_req_log_level (default error), and those log lines confirm which zone and key are triggering.
The trade-offs you can't configure away
IP-based keys punish shared egress. Everyone behind a corporate NAT, a university proxy, or a carrier-grade NAT shares one IP and therefore one bucket. If that's your audience, key on something else — an API token ($http_authorization or a mapped key) or a session identifier — accepting that unauthenticated endpoints still need an IP-based fallback.
Behind a load balancer, the IP is the balancer's. If NGINX sits behind a cloud LB or CDN, $remote_addr is the balancer's address and your limiter will throttle the balancer itself — effectively rate-limiting all clients as one. Fix this first with ngx_http_realip_module (set_real_ip_from plus real_ip_header X-Forwarded-For) so the true client address is restored before limiting. Test this carefully; a misconfigured realip setup is worse than none.
Limits are per NGINX instance. The shared zone coordinates across worker processes on one machine, but nothing syncs state between multiple NGINX servers behind a load balancer. With three NGINX nodes each at 10 r/s, a client effectively gets up to 30 r/s depending on how traffic is distributed. If you need a global limit, you need an external store (a Redis-backed gateway or API management layer) — NGINX alone won't give it to you.
Where to start
Pick your most expensive endpoint, set a rate slightly below what one client should ever need (rate=10r/s with burst=20 nodelay is a sane starting point for interactive APIs), return 429, and watch the error log for a week. The log lines will tell you whether you're catching abusers or punishing legitimate clients behind NAT — and that evidence, not a guessed number, should drive the final values.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.