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.

Where the counter lives in each option With Nginx limit_req each proxy holds its own shared-memory zone so the fleet total is the configured rate times the node count, while an application limiter reads and writes one shared store so the total matches the configured rate exactly. Same rate=100r/m, two counter scopes Nginx shared-memory zone nginx 1: zone A nginx 2: zone B nginx 3: zone C nginx 4: zone D effective limit: 400 per minute Application limiter pod 1 pod 2 one Redis key per API key, atomic effective limit: 100 per minute the proxy number is per process; the application number is per API

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

nginx
# 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;
    }
}
javascript
// 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 });
});
How burst and nodelay change the shape of admitted traffic Without burst, six parallel browser requests produce one success and five rejections. With burst and nodelay, all six are admitted immediately and the slot refills at the configured rate. With burst alone, all six are admitted but paced, adding latency. Six parallel requests from one page load no burst 1 admitted 5 rejected with 429 page renders broken the classic misconfiguration burst=20 (paced) 6 admitted released one per interval adds up to 500 ms latency good for backends, bad for pages burst=20 nodelay 6 admitted immediately slot refills at rate no added latency the default you want sustained rate is identical in all three; only the treatment of bunched arrivals differs

Gotchas and edge cases

  • rate=100r/m is not 100 at once. Nginx converts it to one request per 600 ms. Without burst, the second request inside the same 600 ms is rejected even though the client is far under 100 per minute.
  • $remote_addr behind a proxy counts the proxy. If a CDN or load balancer terminates the connection, every client collapses into one key. Use $binary_remote_addr only when Nginx is the first hop; otherwise use the CDN’s client-IP header with real_ip_header and a set_real_ip_from allowlist.
  • 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 503 teaches the wrong behaviour. Clients treat 503 as a server fault and retry aggressively; 429 is the signal that means “slow down”. Always set limit_req_status 429.
  • Two limit_req directives 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_key looks 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 reload keeps 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.

bash
# 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 inverted
Reading a 429 to find out which tier rejected it A rejection carrying the X-RateLimit triplet came from the application and is the published contract working; a bare rejection with only Retry-After came from the proxy, which means the edge limit is tighter than the published one. Diagnosing an unexpected 429 curl -is, inspect headers X-RateLimit-* present the application rejected it published contract working headers missing the proxy rejected it edge is tighter than published automate this check: an edge-origin 429 means client back-off logic cannot work

Test 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.