How nginx limit_req works: the leaky-bucket model

This section is for backend and platform engineers running nginx as a reverse proxy or API gateway who need to stop a single client, token or route from overwhelming the services behind it.

Rate limiting in nginx uses two directives. limit_req_zone sits in the http context and defines a shared memory zone, the key that requests are counted against, and a rate. limit_req goes in a server or location block and applies that zone to matching traffic. A minimal setup is limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; in http, plus limit_req zone=api; in the API location. That gives each client address its own counter.

The model is a leaky bucket. Requests drain at a fixed rate, and nginx tracks that rate at millisecond granularity. So 10r/s does not mean "ten requests anywhere within each second." It means one request every 100 ms. If a request arrives 40 ms after the previous one from the same key, it is early, and with no burst configured there is nowhere to hold it. nginx rejects it immediately, with a 503 by default.

This is why the minimal config breaks real browsers. A single-page app loading a dashboard can send six or eight API calls in parallel within a few milliseconds. Only the first one is inside the allowed interval, so the rest fail. The user is far below ten requests per second on average, but the page still breaks. The fix is to give the bucket some room to absorb short spikes, which is what burst provides.

http {
    # One counter per client IP. $binary_remote_addr is the compact
    # binary form of the address, so 10m of shared memory holds many keys.
    # rate=10r/s is enforced as one request per 100 ms, not 10 per second.
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    upstream api_backend {
        server 10.0.0.11:8080;
        server 10.0.0.12:8080;
    }

    server {
        listen 80;
        server_name api.example.internal;

        location /api/ {
            # Naive config: no burst. Any request from the same IP that
            # arrives less than 100 ms after the previous accepted one
            # is rejected at once. An SPA firing 6-8 parallel calls on
            # page load gets the first through and the rest rejected.
            limit_req zone=api;

            # The rejection status defaults to 503; set it explicitly so
            # clients can tell throttling apart from a real outage.
            limit_req_status 429;

            proxy_pass
        }
    }
}

A minimal limit_req_zone and limit_req setup with no burst, where nginx enforces 10r/s as one request per 100 ms per client IP and rejects early requests at once, so parallel page-load calls from a browser fail.

burst, nodelay and delay: what each one does to your traffic

what the burst parameter does. The three variants below behave very differently under the same spike, so it helps to trace one through each.

Illustrative estimate. Assume rate=5r/s (one request every 200 ms), burst=10, and 15 requests arriving at the same instant from one key.

burst alone

One request is forwarded immediately. The next 10 fill the burst queue, and nginx releases them one every 200 ms. The last 4 find the queue full and are rejected. Nothing is dropped that fits in the queue, but the tenth queued request waits about 2 seconds. The backend sees a smooth trickle, and the client sees a slow page.

burst with nodelay

With nodelay, 11 requests go upstream at once and 4 are rejected. The queue slots are still marked as used and free up only at the base rate of one every 200 ms. A second spike shortly afterwards will mostly be rejected. Latency disappears, but your backend now has to handle 11 concurrent requests from a single client instead of a steady one at a time.

burst with delay=N

delay=N gives you a middle ground. With delay=4, one request plus the first 4 excess requests pass immediately, the next 6 are queued at 200 ms intervals, and 4 are rejected. Older nginx releases do not support delay=, so check your version's documentation before relying on it.

Config Worst-case added latency Peak backend concurrency (one client) Rejected
burst=10 ~2 s 1 4
burst=10 nodelay none 11 4
burst=10 delay=4 ~1.2 s 5 4
# Illustrative config: three ways to absorb the same spike.
# Each variant uses its own zone so the locations do not share counters.
http {
    # 5 r/s per client IP = one request every 200 ms.
    limit_req_zone $binary_remote_addr zone=queued:10m  rate=5r/s;
    limit_req_zone $binary_remote_addr zone=instant:10m rate=5r/s;
    limit_req_zone $binary_remote_addr zone=hybrid:10m  rate=5r/s;

    # Return 429 instead of the default 503 for requests that do not fit in the burst.
    limit_req_status 429;

    upstream app {
        server 127.0.0.1:8080;
    }

    server {
        listen 80;

        # burst alone: excess requests are queued and released every 200 ms.
        # Smooth backend load, up to ~2 s of added latency.
        location /queued/ {
            limit_req zone=queued burst=10;
            proxy_pass
        }

        # burst + nodelay: the whole burst is forwarded at once.
        # No added latency, but slots still free up at only 5 r/s.
        location /instant/ {
            limit_req zone=instant burst=10 nodelay;
            proxy_pass
        }

        # burst + delay=4: the first 4 excess requests pass immediately,
        # the remaining 6 are queued at the base rate.
        # delay= is not available in older nginx releases; check your version's docs.
        location /hybrid/ {
            limit_req zone=hybrid burst=10 delay=4;
            proxy_pass
        }
    }
}

An nginx config that applies the three burst variants from the table, plain burst, burst with nodelay and burst with delay=4, to separate locations, each with its own 5 r/s per-IP zone.

Choosing the key and sizing the zone

The key decides what counts as "one client," and its size determines how many clients fit in the zone. Nginx stores one state per distinct key value in the shared memory zone, so the key's length affects capacity directly.

Key Key size Notes
$binary_remote_addr 4 bytes (IPv4), 16 bytes (IPv6) Fixed length, so each state is a predictable size
$remote_addr 7–15 bytes (IPv4), up to 39 (IPv6) Text form, larger and variable-length
$http_x_api_key Header length Limits per tenant rather than per address
Constant string One state Gives a single global ceiling for a location

Prefer $binary_remote_addr. It identifies the same client in fewer bytes. The nginx documentation puts a state at about 64 bytes on 32-bit platforms and 128 bytes on 64-bit platforms, which works out to roughly 16,000 or 8,000 states per megabyte. On a typical 64-bit host, zone=api:10m therefore holds about 80,000 clients (illustrative estimate).

When the zone fills, nginx evicts the least recently used states. If it still cannot allocate a new state, it rejects the request with an error. An undersized zone loses state under high-cardinality traffic, such as address scans or clients rotating through an IPv6 prefix. Evicted clients get a fresh bucket, so the limit stops being enforced for them. An oversized zone just holds shared memory you never use.

Keys other than the client address cost more to maintain. Each extra limit_req_zone adds a memory budget to size and a set of thresholds to tune, so give separate locations their own zones only when their limits actually differ.

http {
    # Per-client limit keyed on the binary address: 4 bytes per IPv4 key,
    # 16 per IPv6. 10m holds roughly 80,000 states on 64-bit (illustrative estimate).
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    # Per-tenant limit keyed on an API key header. Requests without the
    # header have an empty key and are not counted against this zone.
    limit_req_zone $http_x_api_key zone=tenant:5m rate=50r/s;

    # Global ceiling: $server_name has one value per server block,
    # so the zone only ever needs a single state.
    limit_req_zone $server_name zone=search_global:1m rate=200r/s;

    # Return 429 instead of the default 503 for rejected requests.
    limit_req_status 429;

    server {
        listen 80;
        server_name api.example.internal;

        location /api/ {
            # Apply the per-IP and per-tenant limits together.
            # A request must pass both.
            limit_req zone=api burst=20 nodelay;
            limit_req zone=tenant burst=100 nodelay;
            proxy_pass
        }

        location /api/search {
            # Expensive endpoint: its threshold really differs, so it
            # justifies its own zone on top of the per-IP limit.
            limit_req zone=api burst=20 nodelay;
            limit_req zone=search_global burst=50;
            proxy_pass
        }
    }
}

Three limit_req_zone keys sized to their cardinality: a per-client $binary_remote_addr zone, a per-tenant API key zone, and a single-state global ceiling used only on the endpoint whose limit differs.

Keying limits on the real client IP behind a load balancer or CDN

Behind a load balancer or CDN, the TCP connection nginx accepts comes from the proxy, not the browser. $remote_addr (and $binary_remote_addr) then holds the proxy's address, so every client lands in one shared bucket. One busy customer can trigger 429s for everyone, and the limit stops distinguishing abusers from normal users.

Client 198.51.100.7          (untrusted: may send any X-Forwarded-For)
   |  XFF: <whatever client sent>
   v
CDN edge 192.0.2.10           (trusted: provider-published range)
   |  appends 198.51.100.7  ->  XFF: ..., 198.51.100.7
   v
Load balancer 10.0.1.5        (trusted: your VPC CIDR)
   |  appends 192.0.2.10    ->  XFF: ..., 198.51.100.7, 192.0.2.10
   v
nginx  $remote_addr = 10.0.1.5   (before realip)

ngx_http_realip_module fixes this by rewriting $remote_addr before limit_req evaluates its key. It is not always compiled in. Run nginx -V and look for --with-http_realip_module.

The accompanying config lists the following:

  • set_real_ip_from: one line per trusted CIDR. In this example, those are the CDN ranges and 10.0.1.0/24.
  • real_ip_header: usually X-Forwarded-For, or the client-IP header your CDN documents.
  • real_ip_recursive on: walks the header from right to left, skips trusted addresses, and selects the first untrusted address, which is 198.51.100.7 in the diagram.

set_real_ip_from is the trust boundary. List only proxies you operate or ranges your provider publishes, and never 0.0.0.0/0. If you trust too broadly, nginx accepts whatever a client writes into X-Forwarded-For. An attacker can then rotate a fake IP on every request. Each request gets a fresh bucket, which bypasses the limit, and the stream of new keys fills the shared-memory zone.

If you trust too narrowly, for example by omitting a new CDN range, nginx stops at that proxy's address. Traffic collapses back into a few buckets, and you throttle your own users. Keep the CIDR list in sync with your provider's published ranges.

# Requires ngx_http_realip_module: confirm with `nginx -V 2>&1 | grep -o with-http_realip_module`
http {
    # Trust boundary: only proxies we operate or ranges the CDN publishes.
    # Placeholder CDN range (use your provider's published list, kept up to date).
    set_real_ip_from 192.0.2.0/24;
    # Our load balancer subnet inside the VPC.
    set_real_ip_from 10.0.1.0/24;
    # NEVER: set_real_ip_from 0.0.0.0/0;  (lets clients choose their own key)

    # Header to read the client address from. Swap in your CDN's documented
    # client-IP header if it sets one and you only accept traffic via the CDN.
    real_ip_header X-Forwarded-For;

    # Walk XFF right to left, skip trusted hops, and use the first untrusted
    # address (198.51.100.7 in the diagram), not the leftmost value a client wrote.
    real_ip_recursive on;

    # $binary_remote_addr now holds the rewritten client IP, so each client
    # gets its own bucket instead of sharing the load balancer's.
    limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;

    # Log both values so you can verify realip is doing what you expect.
    log_format realip '$remote_addr via $realip_remote_addr '
                      '"$http_x_forwarded_for" $status';

    server {
        listen 80;
        access_log /var/log/nginx/access.log realip;

        location /api/ {
            limit_req zone=per_ip burst=20 nodelay;
            limit_req_status 429;
            proxy_pass
        }
    }
}

Configures ngx_http_realip_module to trust only the CDN and load balancer ranges and recover the real client IP from X-Forwarded-For, so limit_req keys on each client instead of on the proxy's address.

Implementing it safely: 429s, logging, dry runs and layered limits

Start with the status code. By default nginx rejects throttled requests with 503, which looks the same as a backend outage in dashboards and alerts. Set limit_req_status 429; so rate limiting shows up as its own signal and clients can tell "slow down" apart from "broken." nginx does not add a Retry-After header by default, so how clients back off depends on the client. If you control the clients, document the expected retry behavior. If you don't, assume some will retry immediately.

Next, set limit_req_log_level. Rejections are logged at error by default, and delayed requests are logged one level lower. On a busy limit that can flood error.log. Use warn or notice so rejections stay visible without burying real errors.

For new limits, use limit_req_dry_run on; where your nginx build supports it. This directive is version-dependent, so confirm it in the docs for your release. In dry-run mode nginx counts requests against the zone and logs would-be rejections, but it does not enforce them. You can then check the numbers against real traffic before any user gets a 429.

You can layer several limit_req directives in one location, for example a per-IP key plus a per-endpoint or global key. Every limit applies, so the most restrictive one decides the outcome. Inheritance has a catch. A level inherits limit_req from http or server only if it defines none of its own. Adding one limit to a location silently drops all the inherited ones, so redeclare them there.

To test, send traffic with ab or a curl loop while running tail -f on error.log. Count the 429s and confirm which zone triggered each one. Dry runs and unambiguous 429s shorten debugging and make a rollback much less likely.

When to use limit_req and when to avoid it

Good fits

limit_req works best when one request maps to roughly one unit of backend work and the clients are predictable. Expensive endpoints are the clearest case: report generation, search, file exports, anything that holds a database connection or a worker for seconds. A modest per-key rate keeps one caller from monopolizing that capacity. Login, password reset and token endpoints also fit, because legitimate users rarely submit more than a few attempts per minute, so a tight limit slows credential stuffing with little cost to real users. APIs whose clients are authenticated services or SDKs with retry logic handle a 429 gracefully, which lets you enforce a contract instead of guessing.

Where it falls short

Per-IP keys punish shared addresses. An office behind one corporate proxy, a university NAT or a mobile carrier's CGNAT can put thousands of users on a single IP, and they all share one bucket. An attacker with a botnet or rotating proxies spreads requests across many IPs and barely notices the limit.

Every request costs the same to limit_req. A health check and a full-text search each consume one token, so a request rate is only a rough stand-in for load.

Counters live in each nginx instance's shared memory. Nodes do not coordinate, so with four nodes behind round-robin the effective limit is about four times the configured rate. With sticky or uneven balancing, the effective limit becomes unpredictable.

Finally, it is not DDoS protection. Volumetric floods saturate links, and TLS handshake floods exhaust CPU, before nginx ever parses an HTTP request.

Alternatives and impact on cost and engineering time

That is why limit_req is one layer among several, not a complete answer.

Approach Scope Coordination across nodes Granularity Operational burden
limit_req Request rate per nginx instance None; each node counts alone Any nginx variable (IP, header, URI) Low; config only
limit_conn Concurrent connections per instance None Same keys as limit_req Low
Application limits with a shared store Cluster-wide Yes, via the store Per user, tenant, plan or cost of operation High; code, store availability, latency
Edge or CDN limits Before traffic reaches you Handled by the provider Usually IP, path, headers Medium; separate config and deploy path
Network-layer DDoS mitigation Volumetric and protocol floods Upstream of your network Coarse Low day to day, contractual setup

limit_req is cheap to adopt because it needs no new infrastructure. The real cost is testing time. You have to tune burst against real traffic, decide which proxies real_ip may trust, and size zones so they don't evict keys. Skipping that work is a false saving. A trusted-proxy mistake that keys every request on the load balancer's address will throttle all users at once, and that outage costs more than the testing would have.

Move limits into the application when the key isn't visible to nginx, such as an authenticated user or tenant. Do the same when requests differ widely in cost, so you can charge an expensive export more than a cached read, or when per-node counting drifts too far from the contractual limit you publish. Until one of those applies, extra engineering effort buys little that a well-tested limit_req doesn't already provide.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nginx limit_req, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.