limit_req
and
limit_conn
directives, zone memory sizing for tens of thousands of clients, burst and nodelay tuning to handle legitimate traffic spikes without queueing delays, per-endpoint limits for API protection, and logging to troubleshoot false positives. You will see real config snippets, memory calculations, and the exact log messages nginx writes when it rejects requests.
How nginx Rate Limiting Works
nginx rate limiting uses two pairs of directives. Thelimit_req_zone
directive defines a shared memory zone that tracks request rates by a key (usually client IP), and
limit_req
applies that zone to a location. The
limit_conn_zone
and
limit_conn
pair works similarly but counts concurrent connections instead of request rate. Most production scenarios need
limit_req
for request throttling;
limit_conn
handles slow clients holding connections open.
The Leaky Bucket Algorithm
limit_req
implements a leaky bucket: requests arrive and fill the bucket, which drains at the configured rate. If the bucket overflows, nginx rejects the request with a 503. Here is a basic configuration:
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api;
proxy_pass http://backend;
}
}
}
This creates a 10 MB zone named
api
that tracks each client IP (
$binary_remote_addr
) and allows 10 requests per second. When a client exceeds 10 r/s, nginx logs
limiting requests, excess: 5.000 by zone "api"
and returns 503. The
excess
value shows how many requests per second over the limit the client sent. Your access log shows the rejected request with status 503 and the error log records the reason.
Zones, Keys and Shared Memory
The zone is shared memory visible to all nginx worker processes. The key determines what you are limiting:$binary_remote_addr
is the client IP in binary form (4 bytes for IPv4, 16 bytes for IPv6), which is more memory-efficient than the text form
$remote_addr
. Each unique key consumes about 128 bytes on x86_64 (the key itself plus tracking metadata). For
$binary_remote_addr
, 1 MB holds roughly 8,000 IPv4 addresses or 4,000 IPv6 addresses.
limit_conn
works the same way but counts active connections, not request rate:
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
location /downloads/ {
limit_conn conn_per_ip 5;
}
}
This limits each IP to 5 concurrent connections to
/downloads/
. Use
limit_conn
to prevent a single client from monopolizing worker connections, especially for slow downloads or long-polling endpoints. The
ngx_http_limit_req_module documentation
covers all parameters and edge cases.
Configuring limit_req Zones and Rates
limit_req_zone
takes three parameters: the key variable,
zone=name:size
, and
rate=Xr/s
(or
r/m
for requests per minute). The key is almost always
$binary_remote_addr
unless you are behind a proxy, in which case you might use
$http_x_forwarded_for
, though that is trivially spoofed. For multi-tenant setups,
$server_name
or
$http_host
lets you limit per virtual host. Keys like
$request_uri
or
$uri
let you track specific paths, useful for protecting a single expensive endpoint.
Zone Memory Sizing
Calculate zone size based on expected unique clients per hour. Each IPv4 address consumes about 128 bytes in the zone (64 bytes for the entry, 64 bytes overhead). If you expect 50,000 unique IPs per hour, allocate 50,000 ร 128 = 6.4 MB; round up to 8 MB for safety. IPv6 addresses need the same 128 bytes per entry despite the larger key size. When the zone fills, nginx evicts the least recently used entries, so size it to hold your peak hourly unique clients.limit_req_zone $binary_remote_addr zone=api:8m rate=10r/s;
limit_req_zone $binary_remote_addr zone=static:4m rate=100r/s;
Here
api
is sized for 50k IPs,
static
for 25k. You can define multiple zones with different rates and apply them to different locations. If your server sits behind a load balancer or CDN,
$binary_remote_addr
will be the load balancer IP; use the
realip
module to extract the real client IP from
X-Forwarded-For
or
X-Real-IP
headers.
Choosing the Rate and Key
Rate depends on your application. For a read-heavy API, 10 r/s per IP is strict, 100 r/s is loose. For a login endpoint, 1 r/s with a burst (covered next) prevents brute force without blocking typos. For static assets served by nginx directly, 200-500 r/s is reasonable since nginx handles those with minimal overhead. Tune by watching your logs: if you see legitimate traffic getting 503s during normal usage, raise the rate or add burst.limit_req_zone $binary_remote_addr zone=login:2m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=assets:5m rate=200r/s;
server {
location /login {
limit_req zone=login burst=5;
}
location /api/ {
limit_req zone=api;
}
location /static/ {
limit_req zone=assets;
}
}
This applies stricter limits to authentication, moderate limits to API endpoints, and loose limits to static files. Each zone tracks the same key but enforces different rates.
Burst and nodelay: Handling Traffic Spikes
Withoutburst
, any request over the rate limit is rejected immediately. A user refreshing a page or a client retrying a failed request can easily exceed 1 r/s for a moment.
burst
lets you queue a small number of excess requests instead of rejecting them outright.
burst=20
allows up to 20 requests to queue; they are served at the configured rate, not immediately.
The burst Parameter
Withrate=10r/s burst=20
, a client sending 30 requests in one second gets 10 served immediately (the rate), 20 queued (the burst), and 10 rejected. The queued 20 are served over the next 2 seconds at 10 r/s. This smooths out short spikes without blocking them. The client sees the first 10 responses instantly, then the next 20 trickle in, then 503s for the rest.
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
location /api/ {
limit_req zone=api burst=20;
}
Your error log shows
delaying request
for queued requests and
limiting requests, excess: X
for rejected ones. The access log records delayed requests with normal 200 status but they took longer to complete. For API clients that can tolerate a bit of backpressure, queueing is fine. For web pages where a human is waiting, the delay is annoying.
nodelay vs Queueing
nodelay
serves the burst immediately instead of queueing it. With
rate=10r/s burst=20 nodelay
, a client sending 30 requests gets 30 served instantly (10 from the rate, 20 from the burst), then the next request is delayed until the bucket refills. This is better for human users: reloading a page sends 5-10 requests for HTML, CSS, JS, images; queueing those creates a sluggish experience.
limit_req zone=api burst=20 nodelay;
The tradeoff:
nodelay
does not smooth traffic, it just prevents immediate rejection. A client can still send 30 r/s every second by using the burst, so the backend still sees spikes. For API endpoints where you want to enforce the rate strictly, omit
nodelay
. For user-facing pages, add it. The error log shows
limiting requests, excess: X by zone "api", client: 192.0.2.1
for rejected requests but no
delaying
messages since the burst is served immediately.
limit_conn for Concurrent Connection Limits
limit_conn
counts active connections per key, not request rate. A connection is active from the moment nginx accepts it until the response completes. Use
limit_conn
to prevent a single client from holding open too many connections, which can exhaust worker connections or backend pools. The classic use case is protecting against slowloris attacks, where a client opens hundreds of connections and sends partial requests slowly to tie up server resources.
When to Use limit_conn
limit_conn_zone
defines the zone,
limit_conn
applies the limit. Here is a config that limits each IP to 10 concurrent connections:
http {
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
server {
location / {
limit_conn conn_limit 10;
}
}
}
When a client opens an 11th connection, nginx rejects it with 503 and logs
limiting connections by zone "conn_limit", client: 192.0.2.1
. For download endpoints or long-polling APIs, set a lower limit to prevent one client from monopolizing bandwidth. For example, limit downloads to 5 concurrent connections per IP:
location /downloads/ {
limit_conn conn_limit 5;
limit_rate 1m; # also cap bandwidth per connection
}
This prevents a download manager from opening 50 parallel connections. The difference from
limit_req
:
limit_req
counts requests per second,
limit_conn
counts how many requests are in-flight right now. A client making 100 r/s with instant responses might never exceed
limit_conn 10
, but would blow past
limit_req zone=api rate=10r/s
.
Combining limit_req and limit_conn
Apply both in the same location to limit rate and concurrency. Define both zones in thehttp
context, then use both directives in the location:
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_conn conn_limit 10;
proxy_pass http://backend;
}
}
}
This enforces 10 r/s sustained rate and 10 concurrent connections per IP. A client that opens 15 connections gets 10 accepted and 5 rejected. A client sending 50 r/s gets 30 served (10 + burst) and 20 rejected. Order does not matter; nginx evaluates both checks before proxying to the backend. The
ngx_http_limit_conn_module documentation
details the connection counting behavior.
Rate Limiting by Endpoint and Condition
Different endpoints have different abuse profiles. A search endpoint that hits the database hard might need 1 r/s, while a cached read endpoint can handle 100 r/s. Static assets served by nginx directly can tolerate 500 r/s since they bypass the backend. Apply differentlimit_req
directives to different locations to match each endpoint's capacity.
Per-Location Limits
Define multiple zones with different rates, then apply each zone to the appropriate location:http {
limit_req_zone $binary_remote_addr zone=search:5m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=static:5m rate=200r/s;
server {
location /api/search {
limit_req zone=search burst=5;
proxy_pass http://backend;
}
location /api/ {
limit_req zone=api burst=20;
proxy_pass http://backend;
}
location /static/ {
limit_req zone=static;
root /var/www;
}
}
}
The
/api/search
location gets the strictest limit because it is expensive. General API endpoints use a moderate limit, static files use a high limit. nginx matches the most specific location first, so
/api/search
matches before
/api/
. No limit on
/static/
is also an option; just omit
limit_req
entirely if you trust clients or have upstream CDN protection.
Conditional Rate Limiting with map
Use amap
to set the rate limit key conditionally. For example, limit bots more aggressively than browsers:
http {
map $http_user_agent $rate_limit_key {
~*(bot|crawler|spider) $binary_remote_addr;
default '';
}
limit_req_zone $rate_limit_key zone=bots:10m rate=1r/s;
server {
location / {
limit_req zone=bots burst=3;
proxy_pass http://backend;
}
}
}
If the
User-Agent
contains "bot", "crawler", or "spider",
$rate_limit_key
is set to the client IP and the limit applies. Otherwise
$rate_limit_key
is empty, which does not match any entry in the zone, so the limit is not enforced. Only bots get throttled. To exempt internal IPs, use a
geo
block:
geo $internal_ip {
default 1;
10.0.0.0/8 0;
192.168.0.0/16 0;
}
map $internal_ip $rate_limit_key {
0 '';
1 $binary_remote_addr;
}
limit_req_zone $rate_limit_key zone=external:10m rate=10r/s;
Internal IPs get
$internal_ip=0
, so
$rate_limit_key
is empty and they bypass the limit. External IPs get limited. This is cleaner than duplicating
location
blocks with
allow
/
deny
rules.
Logging and Monitoring Rate Limit Events
When nginx rejects a request due to rate limiting, it logs to the error log atwarn
level by default. The message looks like this:
2025/01/15 14:23:45 [warn] 12345#12345: *67890 limiting requests, excess: 5.123 by zone "api", client: 192.0.2.1, server: example.com, request: "GET /api/users HTTP/1.1"
The
excess
value shows how many requests per second over the limit the client sent. If
rate=10r/s
and
excess: 5.123
, the client sent 15.123 r/s. Queued requests show
delaying request
instead of
limiting requests
.
Error Log Entries
Adjust the log level withlimit_req_log_level
to reduce noise or increase visibility:
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_log_level info; # default is warn
}
Set it to
info
if you want rate limit messages visible but not alarming, or
error
if you only care about severe issues. Change the HTTP status code returned with
limit_req_status
:
limit_req_status 429; # default is 503
Returning 429 (Too Many Requests) is semantically correct and helps clients distinguish rate limiting from server errors. Some monitoring tools alert on 5xx but ignore 4xx, so 429 reduces false alarms.
Custom Access Log for Rate Limits
The error log tells you when requests were rejected, but correlating with access logs shows the full picture. Add a custom log format that includes$limit_req_status
:
log_format limited '$remote_addr - [$time_local] "$request" $status $limit_req_status';
access_log /var/log/nginx/limited.log limited;
$limit_req_status
is
PASSED
if the request was allowed,
DELAYED
if queued,
REJECTED
if rejected,
REJECTED_DRY_RUN
in dry-run mode, or
-
if no limit applied. Grep for rejected requests:
grep REJECTED /var/log/nginx/limited.log | awk '{print $1}' | sort | uniq -c | sort -rn
This shows which IPs are getting rate limited most often. If you see a spike of
REJECTED
entries during normal traffic, your rate is too low. If you see no
REJECTED
entries during an attack, your rate is too high. For ongoing monitoring, export nginx logs to Prometheus using
nginx-prometheus-exporter
or parse the
stub_status
module output. The
Nginx Configuration Mastery
guide covers setting up structured logging and metrics collection for production environments.
Common Pitfalls and Tuning in Production
Most rate limiting failures in production come from three mistakes: zones too small to track all active clients, using $binary_remote_addr behind a load balancer, and burst values too low for typical page loads. Each one silently breaks protection or blocks legitimate users until you notice the pattern in logs.
Zone Exhaustion and Eviction
When a zone fills, nginx evicts the least recently used entries to make room for new ones. If you have 100,000 unique IPs per hour and a 1 MB zone (about 8,000 IPv4 addresses), active clients get evicted before their rate counters expire. An evicted client can immediately send another burst of requests as if they are new. Your rate limiting becomes ineffective against distributed attacks or high-traffic periods.
Check your access logs to estimate unique IPs per hour:
awk '{print $1}' /var/log/nginx/access.log | sort -u | wc -l
If you see 50,000 unique IPs and your zone is 2 MB, you are evicting active clients. Multiply unique IPs by 128 bytes and add 25% headroom. For 50k IPs: 50,000 ร 128 = 6.4 MB, round up to 8 MB. For 200k IPs: 200,000 ร 128 = 25.6 MB, allocate 32 MB. Large zones cost memory but eviction costs protection. nginx does not expose zone usage via stub_status, so monitor by tracking whether rejected request counts drop during high traffic (a sign of eviction) or by watching the excess values in error logs. If they are consistently high but few requests get rejected, clients are bypassing the limit.
False Positives Behind Load Balancers
$binary_remote_addr is the IP nginx sees, which is the load balancer IP if you are behind AWS ELB, Cloudflare, or any proxy. All clients appear to come from the same IP, so the rate limit applies to your entire user base combined. One legitimate user hitting 10 r/s blocks everyone else.
Use the ngx_http_realip_module to extract the real client IP from X-Forwarded-For or X-Real-IP headers. Configure set_real_ip_from with your load balancer or CDN IP ranges:
http {
set_real_ip_from 10.0.0.0/8; # internal load balancers
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 2001:db8::/32; # IPv6 if applicable
real_ip_header X-Forwarded-For;
real_ip_recursive on;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
}
Now $binary_remote_addr is the real client IP, not the load balancer. real_ip_recursive on walks through the X-Forwarded-For chain to find the first untrusted IP. Only set set_real_ip_from for IPs you control; otherwise an attacker can spoof X-Forwarded-For to bypass limits. The ngx_http_realip_module documentation lists all directives and trust chain behavior.
The third pitfall: burst too low for parallel requests. A typical web page loads HTML, then requests CSS, JS, images in parallel (often 5-10 requests in under a second). With rate=10r/s burst=5, the page load hits the burst limit and subsequent assets get 503 errors. Users see broken styling or missing images. Tune burst to rate ร typical page load time. If pages load in 2 seconds and your rate is 10 r/s, set burst=20. Before tuning:
limit_req zone=api burst=5;
A client loading a page with 15 assets gets 10 served (rate), 5 queued (burst), 10 rejected. After tuning:
limit_req zone=api burst=20 nodelay;
All 15 assets succeed immediately. The nodelay prevents queueing, which would make the page sluggish. Check your error log for limiting requests messages during normal traffic. If you see them, raise the burst.
Testing and Validating Rate Limits
Test rate limits in a staging environment with production-like traffic before deploying. Use curl in a loop to confirm limits work and measure how many requests succeed before rejection:
for i in {1..50}; do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/api; done | sort | uniq -c
With rate=10r/s burst=20, you should see about 30 requests return 200, then the rest return 503 (or 429 if you set limit_req_status 429). The output looks like:
30 200
20 503
If you see more than 30 successful requests, your rate or burst is higher than expected. If you see fewer, check your zone size or whether clients are getting evicted. Use ab to simulate concurrent clients:
ab -n 100 -c 10 https://example.com/api
This sends 100 requests with 10 concurrent connections. The output shows Failed requests and Non-2xx responses; these should match your expected rejection count. For rate=10r/s burst=20, expect 70 failures out of 100 if all requests arrive instantly. Check the error log for confirmation:
grep 'limiting requests' /var/log/nginx/error.log | tail -20
You should see entries like limiting requests, excess: 7.000 by zone "api". The excess value confirms clients are exceeding the rate.
Test burst behavior by sending requests in quick succession. With nodelay, the first 30 requests (10 from rate, 20 from burst) succeed immediately, then nginx starts rejecting. Without nodelay, the first 10 succeed immediately, the next 20 queue and trickle out over 2 seconds, then rejections start. Run the curl loop and check response times:
for i in {1..50}; do curl -s -o /dev/null -w '%{time_total}\n' https://example.com/api; done
With nodelay, all successful responses come back in under 1 second. Without nodelay, you see increasing response times as requests queue. Test from multiple IPs to confirm per-IP isolation: use curl --interface eth0 or run tests from different hosts. If you see rate limits applying globally instead of per-IP, check your realip configuration. You might be tracking the load balancer IP instead of the client.
For realistic validation, replay production access logs in staging using tools like goreplay or tcpreplay. This shows how your limits behave under real traffic patterns, including legitimate bursts during peak hours. Watch for limiting requests messages that correlate with normal usage spikes, which indicate your rate or burst is too low.
Production Rate Limiting Checklist
Before enabling rate limiting in production, verify these points:
- Zone size holds your peak hourly unique IPs with 25% headroom (unique IPs ร 128 bytes ร 1.25).
- If behind a load balancer or CDN,
set_real_ip_fromandreal_ip_headerare configured to extract the real client IP. burstis tuned torate ร typical page load timefor user-facing endpoints, withnodelayto avoid queueing delays.- Different endpoints have appropriate rates: strict for authentication and expensive operations, moderate for APIs, loose for static assets.
limit_req_status 429is set so clients can distinguish rate limiting from server errors.- Error log level is set to
infoorwarnto capture rate limit events without flooding logs. - A custom access log format includes
$limit_req_statusfor monitoring rejected requests. - Test in staging with realistic traffic, including parallel requests and multiple IPs, to confirm limits work without false positives.
For a complete nginx production setup including SSL/TLS, upstream configuration, and security hardening, see the AlmaLinux 9 + NGINX + PHP-FPM guide. It covers building a hardened web server from base OS through application deployment, with rate limiting as one layer of defense.
Frequently Asked Questions
What is the difference between limit_req and limit_conn in nginx?
limit_req limits the request rate (requests per second or per minute) using a leaky bucket algorithm, while limit_conn limits concurrent connections (how many requests are in-flight at the same time). Use limit_req for general API throttling and DDoS protection. Use limit_conn to prevent a single client from monopolizing connections, especially for slow downloads or long-polling endpoints. You can apply both to the same location.
How do I calculate the right zone size for nginx rate limiting?
Each unique key (typically an IP address) consumes about 128 bytes in the zone. Multiply your peak hourly unique IPs by 128 bytes, then add 25% headroom. For 50,000 unique IPs: 50,000 ร 128 = 6.4 MB, round up to 8 MB. For 200,000 IPs, allocate 32 MB. Check your access logs with awk '{print $1}' access.log | sort -u | wc -l to estimate unique IPs. If the zone fills, nginx evicts least recently used entries and limits become ineffective.
Should I use burst with nodelay for API rate limiting?
For user-facing pages, yes. nodelay serves the burst immediately so CSS, JS and images load in parallel without queueing delays. For backend APIs where you want strict rate enforcement, omit nodelay so excess requests queue and drain at the configured rate, applying backpressure to the client. With nodelay, clients can still send bursts every second, so the backend sees spikes. Choose based on whether you prioritize user experience or strict rate enforcement.
How do I rate limit by IP address behind a load balancer or CDN?
Use the ngx_http_realip_module to extract the real client IP from X-Forwarded-For or X-Real-IP headers. Set set_real_ip_from to your load balancer or CDN IP ranges, and real_ip_header X-Forwarded-For. Enable real_ip_recursive on to walk the header chain. Only trust IPs you control; otherwise attackers can spoof headers to bypass limits. After configuration, $binary_remote_addr becomes the real client IP instead of the load balancer IP.
What HTTP status code does nginx return when rate limiting blocks a request?
nginx returns 503 Service Unavailable by default when a request exceeds the rate limit. Change this with limit_req_status 429 to return 429 Too Many Requests, which is semantically correct and helps clients and monitoring tools distinguish rate limiting from actual server errors. The error log records the rejection with limiting requests, excess: X.XXX by zone "name" showing how far over the limit the client was.