NGINX Rate Limiting with ngx_http_limit_req_module: Configuration, Burst Handling, and Operational Pitfalls
Configure NGINX rate limiting with ngx_http_limit_req_module: define shared memory zones, apply burst and nodelay, handle proxied client IPs, and avoid common placement mistakes.
11 Jun 2026, 15:44 UTC

The Problem: Protecting Upstream Services from Request Floods
When a single client or a small group of clients sends requests faster than your upstream application can handle, the service degrades or crashes. NGINX's ngx_http_limit_req_module solves this by enforcing a per-key request rate at the edge, before traffic reaches your application. The module uses a shared memory zone to track request counts across worker processes, applying a leaky-bucket algorithm that can either delay excess requests (smoothing) or reject them immediately (hard limit).
Useful takeaway: Define a limit_req_zone in the http context keyed by client identifier (typically $binary_remote_addr), then apply limit_req in the location or server block that receives the client request. Use burst to absorb short spikes and nodelay to serve burst requests without delay.
Worked Configuration Example
The following snippet shows a complete, minimal setup for an API endpoint. Place the limit_req_zone directive in the http block (e.g., /etc/nginx/nginx.conf). The limit_req directive goes in the location that proxies to your upstream.
http {
# Shared memory zone: 10 MB, keyed by binary client IP, 10 requests/second
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 80;
server_name api.example.com;
location /api/ {
# Enforce the zone: allow bursts of 20, serve burst immediately
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
}
}
}
What this does:
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/screates a 10 MB shared memory zone named "api". Each unique client IP (stored as a 4-byte binary value) gets its own bucket. The rate is 10 requests per second. A 10 MB zone holds roughly 160,000 unique keys.limit_req zone=api burst=20 nodelayapplies the zone to/api/. Clients can exceed 10 r/s up to 20 additional requests (the burst bucket). Withnodelay, those 20 burst requests are processed immediately; without it, NGINX would delay them to maintain the 10 r/s average.
Where to run: Edit the configuration file on the NGINX host (requires root or sudo), then reload with nginx -s reload (requires permission to signal the master process).
Key Selection and Proxy Considerations
The key variable determines what "per client" means. $binary_remote_addr uses the TCP source address in binary form, saving memory versus the text $remote_addr. However, if NGINX sits behind a load balancer or CDN, $remote_addr reflects the proxy's IP, not the real client.
To fix this, configure the ngx_http_realip_module before rate limiting:
http {
# Restore real client IP from trusted proxy
set_real_ip_from 10.0.0.0/8; # CIDR of your load balancer
real_ip_header X-Forwarded-For; # or Proxy-Protocol header
real_ip_recursive on;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# ...
}
Place set_real_ip_from and real_ip_header in the http context so the rewritten $binary_remote_addr is available to limit_req_zone. Without this, all traffic appears to come from the load balancer IP, collapsing every client into a single bucket.
Burst Behavior: Smoothing vs. Hard Limits
The burst parameter defines the bucket size. The nodelay flag changes how the bucket is drained:
- Without
nodelay(default): Excess requests are delayed (held in NGINX) until the leaky bucket drains at the configured rate. The client experiences latency but not rejection, up to the burst limit. Further requests beyond burst receive 503. - With
nodelay: Excess requests up to the burst limit are served immediately. The bucket still leaks at the configured rate, but the client sees no added latency for burst traffic. Requests beyond burst are rejected with 503 immediately.
Choose nodelay when you want to absorb spikes without adding latency (e.g., API bursts from legitimate clients). Choose the default when you need to smooth traffic to protect a fragile upstream that cannot handle concurrent spikes.
Risk: With nodelay, many clients bursting simultaneously can send rate + burst requests per second each to your upstream. Ensure upstream capacity handles the aggregate peak.
Common Mistakes
1. Wrong Directive Placement
limit_req must be in the same location (or server) that receives the client request. Placing it after proxy_pass or in a nested location that doesn't match the incoming request means it never executes. Placing it before authentication is fine — rate limiting should apply before expensive auth checks.
2. Using $remote_addr Instead of $binary_remote_addr
The text representation wastes ~12 bytes per key versus 4 bytes. At scale, this reduces the number of trackable clients by 3x for the same zone size.
3. Ignoring Zone Capacity
If the zone fills (160k keys per MB), new keys receive 503 until old entries expire. Monitor zone usage and size the zone for your expected unique clients plus headroom.
4. Single Zone for Heterogeneous Endpoints
The module does not distinguish HTTP methods or paths within a zone. A login endpoint needing 1 r/s and a read endpoint tolerating 100 r/s must use separate zones:
http {
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=read:10m rate=100r/s;
server {
location /login { limit_req zone=login burst=5 nodelay; proxy_pass http://backend; }
location /data { limit_req zone=read burst=200 nodelay; proxy_pass http://backend; }
}
}
Monitoring and Verification
Log Rejections
Add limit_req_log_level warn; in http, server, or location to log rejected requests to the error log. The log line includes "limiting requests" and the zone name.
Track Outcomes with Custom Logging
The $limit_req_status variable (available since NGINX 1.17.6) captures the result: PASSED, DELAYED, REJECTED, or REJECTED_DRY_RUN. Include it in your access log format:
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" limit_req=$limit_req_status';
access_log /var/log/nginx/access.log main;
Dry-Run Mode for Tuning
Before enforcing, test with limit_req_dry_run on; in the location. This logs violations (as REJECTED_DRY_RUN) without returning 503. Use it to calibrate rate and burst for real traffic patterns.
Load Test Verification
From a test host, run a loop against the endpoint:
for i in {1..30}; do
curl -s -o /dev/null -w "%{http_code}\n" http://api.example.com/api/
done
Expect 200 for the first ~30 requests (10 r/s sustained + 20 burst), then 503. Adjust rate and burst if the threshold differs.
Limitations
- Per-worker synchronization: The shared memory zone synchronizes across workers via atomic operations, but high concurrency can cause slight over-counting due to lock contention.
- No request-type discrimination: GET and POST count equally. Separate zones per location are required for different limits.
- Not a WAF: Rate limiting does not stop slowloris or large payloads. Combine with
client_body_timeoutandclient_max_body_size. - Zone size fixed at reload: Increasing
zone=api:10mto20mrequires a configuration reload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.