Nginx limit_req vs an Application Limiter
Choosing between limit_req in Nginx and a limiter inside your application is really a choice about where the counter lives — in one proxy’s shared memory or in a store every node can reach. This decision sits under the edge and gateway enforcement guide, which covers the tiers in general; here the focus is the concrete comparison, the numbers each option actually produces, and a configuration that uses both without lying to clients.
The problem in concrete numbers
You publish 100 requests per minute per API key. You run four Nginx nodes behind a network load balancer, and eight application pods behind them. Configure limit_req_zone ... rate=100r/m and each Nginx node enforces 100 per minute independently, so a client whose connections spread evenly across the four nodes gets 400 per minute — four times the published number. Nothing in the configuration hints at this; the limit is correct per node and wrong per API.
Now consider the opposite failure. You move everything into the application: eight pods sharing one Redis, exact counting, correct headers. A scraper opens 12,000 connections a second with an invalid key. Every one of those requests completes a TLS handshake, occupies a worker, and performs a Redis round trip before being rejected. The limiter is correct and your pods are saturated anyway.
Both tiers are answering different questions, and the mistake is asking one of them to answer both.
Decision matrix
| Criterion | Nginx limit_req |
Application limiter (Redis) |
|---|---|---|
| Cost per rejected request | ~30 µs, no upstream connection | 1 connection + 1 store round trip (~1–3 ms) |
| Counter scope | One process’s shared memory | Fleet-wide |
| Effective limit with N nodes | configured × N | configured |
| Identity available | IP, header, path, TLS fields | Anything: key, account, plan, endpoint cost |
| Algorithm | Leaky bucket with burst queue | Any: token bucket, GCRA, sliding window |
Emits X-RateLimit-* |
No (status only, without extra modules) | Yes, from the same decision |
| Reacts to plan changes | Requires config reload | Immediate |
| Behaviour when the store is down | N/A — no store | Needs an explicit fail-open/fail-closed policy |
| Config change cost | Reload (graceful) | Deploy or dynamic config |
| Blast radius of a mistake | Whole vhost | One endpoint or key class |
The practical reading: Nginx is a shield, the application is a contract. Use limit_req for numbers you would be comfortable describing as “roughly”, and the application for numbers you print in your API documentation.
Step-by-step: run both without contradiction
# Edge: volumetric only. Deliberately far above the published per-key limit.
limit_req_zone $binary_remote_addr zone=perip:16m rate=30r/s;
limit_req_status 429;
limit_req_log_level warn;
map $http_x_api_key $has_key { default 0; "~.+" 1; }
server {
listen 443 ssl http2;
server_name api.example.com;
# 30 r/s sustained, 120 queued and released immediately: absorbs page loads,
# stops a scraper. The published limit (100/min per key) lives in the app.
limit_req zone=perip burst=120 nodelay;
location /v1/ {
proxy_pass http://app_upstream;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Let the app know a request already passed the edge tier.
proxy_set_header X-Edge-Verdict $limit_req_status;
}
}// Application: the published contract. Exact per-key counting, full headers.
const limiter = createTokenBucket({ capacity: 100, refillPerSec: 100 / 60 });
app.use(async (req, res, next) => {
const key = req.header("X-API-Key");
if (!key) return res.status(401).json({ error: "missing_api_key" });
const { allowed, remaining, retryAfterSec } = await limiter.consume(`rl:${key}`);
res.set("X-RateLimit-Limit", "100");
res.set("X-RateLimit-Remaining", String(remaining));
res.set("X-RateLimit-Reset", String(retryAfterSec));
if (allowed) return next();
res.set("Retry-After", String(Math.max(1, retryAfterSec)));
return res.status(429).json({ error: "rate_limited", retry_after: retryAfterSec });
});Gotchas and edge cases
rate=100r/mis not 100 at once. Nginx converts it to one request per 600 ms. Withoutburst, the second request inside the same 600 ms is rejected even though the client is far under 100 per minute.$remote_addrbehind a proxy counts the proxy. If a CDN or load balancer terminates the connection, every client collapses into one key. Use$binary_remote_addronly when Nginx is the first hop; otherwise use the CDN’s client-IP header withreal_ip_headerand aset_real_ip_fromallowlist.- Zone exhaustion is silent to clients. When the zone fills, Nginx evicts the oldest entries and logs a warning; limits become porous exactly when traffic is highest. Size for peak distinct keys and watch the warning rate.
- Default
503teaches the wrong behaviour. Clients treat503as a server fault and retry aggressively;429is the signal that means “slow down”. Always setlimit_req_status 429. - Two
limit_reqdirectives both apply. Multiple zones in the same context are evaluated together and the most restrictive wins — useful for a global plus per-route pair, surprising if you expected the inner one to override the outer. - Keying by
$http_x_api_keylooks tempting. It works, but the header is client-controlled and unvalidated at the edge: an abuser rotates the value and gets a fresh bucket each time. Key the edge by IP; key the application by the authenticated identity. - Reloads reset nothing, restarts reset everything.
nginx -s reloadkeeps the shared memory zone; a full restart clears it, briefly granting every client a fresh burst.
Verification and testing
Prove the two tiers behave as designed with two measurements: one that the edge lets a normal page load through, and one that the published limit is what a single key actually gets.
# 1. Six parallel requests (a page load) must all succeed at the edge.
seq 1 6 | xargs -P6 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-API-Key: acct_test" https://api.example.com/v1/search | sort | uniq -c
# expected: 6 x 200 (if you see 429s, raise burst)
# 2. The published per-key limit: drive one key past 100/min and count accepts.
ACCEPTED=$(for i in $(seq 1 150); do
curl -s -o /dev/null -w "%{http_code}\n" -H "X-API-Key: acct_test" \
https://api.example.com/v1/search
done | grep -c 200)
echo "accepted: $ACCEPTED" # expected: ~100, regardless of node count
# 3. The edge must be looser than the app: a 429 should carry app headers.
curl -is -H "X-API-Key: acct_test" https://api.example.com/v1/search | \
grep -iE "HTTP/|x-ratelimit-remaining|retry-after"
# a 429 with no X-RateLimit headers means the EDGE rejected it — your tiers are invertedTest three is the one to automate. A 429 without application headers proves the edge is tighter than the published limit, which makes every client’s back-off logic unreliable. Add it to the CI checks that run against staging.
Frequently Asked Questions
Can Nginx enforce a per-API-key limit correctly?
It can count by any variable, including a header, but two limitations remain: the counter is per Nginx process, so the fleet total is multiplied by the node count, and the key is unauthenticated at that layer, so a client can mint new buckets by changing the header. Use it for IP-scoped volumetric limits and keep authenticated per-key limits in the application.
Should I divide the Nginx rate by the number of nodes?
Only if the node count is stable. Dividing keeps the fleet total roughly correct but breaks under autoscaling and punishes clients whose traffic lands unevenly. For a shield limit set well above legitimate use, the multiplication does not matter; for a contract limit, move the counter into a shared store instead.
What burst value should I start with?
Roughly twice the per-second rate, with nodelay, then tune from the measured distribution. Browsers open six or more parallel connections and single-page applications fire several requests per view, so a burst smaller than a typical page load produces rejections that look random to users.
Does limit_req add measurable latency?
Not meaningfully: the check is a hash lookup and a counter update in shared memory, tens of microseconds. Latency appears only when you use burst without nodelay, because queued requests are deliberately paced — which is a configuration choice, not overhead.
Related
- Edge & Gateway Enforcement — the parent guide covering all four enforcement tiers.
- Layering Edge and Origin Rate Limits — choosing the multiple between the tiers.
- Redis Counter Architecture — the shared store the application limiter depends on.
- Leaky Bucket Mechanics — the algorithm behind
limit_req’s burst queue.