How limit_req works: a leaky bucket, not a counter

Nginx's limit_req module throttles requests at the proxy layer, before they reach your application. It is a good fit for backend and platform engineers who need to protect upstreams from abusive clients, retry storms or runaway scripts without changing application code. It is a common choice for API throttling because it rejects excess traffic cheaply, before an upstream connection is opened.

Configuration has two parts. The limit_req_zone directive, placed in the http context, defines three things:

  • A key, such as $binary_remote_addr, which decides what gets limited.
  • A named shared memory zone and its size, where per-key state is stored and shared across worker processes.
  • A rate, expressed in requests per second (r/s) or per minute (r/m).

The limit_req directive then applies that zone to a server or location.

Rate is a spacing rule, not a quota

Nginx does not count requests per one-second window. It tracks state at millisecond granularity and enforces a minimum interval between requests. A rate of 10r/s means one request every 100ms. It does not mean 10 requests at any moment within each second. A rate of 30r/m means one request every two seconds.

If a client sends 10 requests in the same instant under a plain 10r/s limit, the first is accepted. The others are rejected, with 503 by default, even though the client has not sent "more than 10 per second" in the counting sense.

This is the leaky-bucket model. Each key has a bucket that drains at the configured rate. Requests that arrive faster than it drains count as excess. Without further configuration, the bucket has no room, so any excess is rejected immediately. The burst, nodelay and delay parameters control how much excess the bucket can hold and when queued requests are released.

http {
    # Key: client IP in compact binary form (4 bytes for IPv4, 16 for IPv6).
    # Zone: shared memory named "api_per_ip", 10 MB, visible to all workers.
    # Rate: 10r/s, enforced as one request every 100ms per key.
    # It is not a counter of 10 requests per one-second window.
    limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s;

    # Slower limit for an expensive endpoint: 30r/m = one request every 2s.
    limit_req_zone $binary_remote_addr zone=export_per_ip:10m rate=30r/m;

    upstream app_backend {
        server 127.0.0.1:8080;
    }

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

        location /api/ {
            # Apply the zone. Without burst the bucket has no room, so a
            # request that arrives less than 100ms after the previous
            # accepted one from the same IP is rejected immediately.
            limit_req zone=api_per_ip;
            proxy_pass
        }

        location /api/export {
            limit_req zone=export_per_ip;
            proxy_pass
        }
    }
}

A minimal limit_req setup that defines per-IP zones at 10r/s (one request per 100ms) and 30r/m (one request per 2s). It applies them without burst, so any request that arrives ahead of the drain interval is rejected with the default 503.

Burst, nodelay and delay: what happens to excess requests

No burst: excess is rejected

With only rate set, nginx enforces the interval strictly. At 10r/s, a request that arrives less than 100 ms after the previous one for the same key is rejected, with a 503 by default. A browser that opens six parallel connections for a page's assets, or a client that fans out a batch, will see failures even though its average rate is well under the limit.

burst: excess waits

burst=N lets up to N excess requests queue. Nginx releases them at the configured rate, so the last request in a full queue waits roughly N × interval. Anything beyond N is rejected. Clients succeed, but they pay for it in latency.

nodelay: excess passes, but capacity drains slowly

nodelay forwards queued requests immediately. The burst slots are still freed only at the rate, so a full burst leaves no room for a second spike that arrives soon after.

Illustrative estimate (assumed: rate=10r/s, burst=10 nodelay, one key). At t=0, 11 requests arrive and all are forwarded. At t=0.5 s, 11 more arrive. Only about 5 slots have drained, so about 5 pass and about 6 are rejected.

delay=N: some excess passes, the rest waits

delay=N forwards the first N excess requests immediately and throttles the remainder of the burst. Anything beyond the burst is rejected.

Setting Excess within burst Beyond burst
none rejected rejected
burst waits rejected
burst nodelay passes rejected
burst delay=N first N pass, rest wait rejected
http {
    # One shared zone keyed by client IP: 10 MB of state, 10 requests/second.
    # At 10r/s nginx allows one request per 100 ms per key.
    limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

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

    server {
        listen 80;

        # 1. No burst: anything faster than 100 ms apart is rejected.
        location /strict/ {
            limit_req zone=perip;
            proxy_pass
        }

        # 2. burst: up to 10 excess requests wait and are released at 10r/s.
        #    The 11th excess request is rejected.
        location /queued/ {
            limit_req zone=perip burst=10;
            proxy_pass
        }

        # 3. burst + nodelay: up to 10 excess requests pass at once,
        #    but the slots still free up at 10r/s.
        location /api/ {
            limit_req zone=perip burst=10 nodelay;
            proxy_pass
        }

        # 4. burst + delay: the first 5 excess requests pass at once,
        #    the next 5 are throttled, and anything beyond 10 is rejected.
        location /assets/ {
            limit_req zone=perip burst=10 delay=5;
            proxy_pass
        }
    }
}

One rate-limit zone used by four locations: strict rejection, burst queueing, burst with nodelay, and burst with delay=N, each choosing whether excess requests wait, pass or are rejected.

Choosing the key and sizing the zone

The key decides who shares a bucket. The zone decides how many buckets Nginx can track at once.

Client IP keys

Key Stored value Memory impact
$binary_remote_addr 4 bytes (IPv4) or 16 bytes (IPv6) Fixed-size key, so each state is smaller and more of them fit per megabyte
$remote_addr Text form, up to 15 characters for IPv4 and 39 for IPv6 Larger, variable-length keys, so fewer fit per megabyte

Prefer $binary_remote_addr when you key on the client address. A per-IP key treats everyone behind one address as a single client. Offices behind a NAT or corporate proxy, and mobile users on carrier-grade NAT, can share one IP. A limit that suits one person will throttle the whole group.

Per-tenant keys

For API throttling, key on the credential, such as $http_x_api_key or a variable derived from the Authorization header. Nginx does not count requests whose key evaluates to an empty string. You can use this to exempt traffic on purpose. It also means unauthenticated requests bypass the limit unless a second zone covers them.

Clients choose these keys, so they can rotate them to escape a limit or to flood the zone with junk entries. Before using a header as a key, validate it. Use a map that passes only well-formed values, or key on an identity your auth layer has already verified.

Sizing

When the zone is full, Nginx evicts the least recently used entries. If it still cannot allocate space for a new key, it rejects that request with an error. Estimate your peak number of distinct active keys, size the zone for that count with headroom, and bound the key space so nobody can generate unlimited keys.

# http context

# Pass only well-formed API keys (32 alphanumeric chars, an assumed format).
# Anything else maps to "", which limit_req does not count.
map $http_x_api_key $tenant_key {
    default                     "";
    "~^[A-Za-z0-9]{32}$"        $http_x_api_key;
}

# Requests without a valid key fall back to a per-IP key, so they
# cannot bypass limiting just by omitting or mangling the header.
map $tenant_key $anon_key {
    ""       $binary_remote_addr;
    default  "";
}

# Per-tenant zone. Size it for the expected peak count of distinct keys,
# plus headroom. Text keys are larger than binary IPs, so each MB holds fewer.
limit_req_zone $tenant_key zone=per_tenant:10m rate=20r/s;

# Per-IP zone for unauthenticated traffic. $binary_remote_addr keeps
# each state small (4 or 16 bytes of key).
limit_req_zone $anon_key   zone=per_anon_ip:10m rate=2r/s;

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

    location /api/ {
        # Each request gets a non-empty key in exactly one zone.
        limit_req zone=per_tenant  burst=40 nodelay;
        limit_req zone=per_anon_ip burst=5  nodelay;
        limit_req_status 429;

        proxy_pass
    }
}

Throttles validated API keys per tenant, drops malformed keys to an empty value, and covers keyless requests with a per-IP zone keyed on $binary_remote_addr.

Implementing tiers, exemptions and stacked limits

Exemptions and tiers through the key

Nginx does not count a request whose key evaluates to an empty string, so exemptions and tiers come from manipulating the key, not from extra if blocks. A geo block flags internal CIDR ranges, and a map turns that flag into either "" or the client address. Because limit_req_zone has a fixed rate, tiers need one zone per tier. A map on the tenant's plan (for example, from an API-key header) emits the tenant ID only for the zone matching that plan and an empty key for the others. Each request is then counted by exactly one tier's zone.

Stacking limits

Several limit_req directives at the same level all apply. A typical pair is a per-IP limit and a per-tenant limit. The most restrictive outcome wins, so one tenant spread across many IPs still hits its tenant ceiling, and one abusive IP inside a large tenant is still capped.

Rejections clients can act on

Set limit_req_status 429. The default 503 tells clients and monitoring that the server is broken. Use limit_req_log_level warn so rejections stand out. Delayed requests are logged one level lower. Map error_page 429 to a location that returns a short JSON body and a Retry-After header so well-behaved clients back off.

Keying on the real client

Behind a load balancer or CDN, $remote_addr is the proxy, so every client shares one bucket. Configure the realip module with set_real_ip_from for each trusted proxy range, real_ip_header X-Forwarded-For, and real_ip_recursive on. Trust only those ranges. Otherwise clients can forge the header and choose their own key.

# http {} context. Trust X-Forwarded-For only from the load balancer range.
set_real_ip_from  10.0.0.0/16;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

# Flag internal ranges; evaluated against the recovered client IP.
geo $internal { default 0; 10.0.0.0/8 1; 192.168.0.0/16 1; }

# Empty key = not counted, so internal clients skip the per-IP limit.
map $internal $ip_key { 1 ""; default $binary_remote_addr; }

# Placeholder plan lookup by API key; unknown keys fall into the free tier.
map $http_x_api_key $plan { default free; "pk_example_pro_123" pro; }

# Each tier zone sees the tenant key only for its own plan.
map $plan $free_key { free $http_x_api_key; default ""; }
map $plan $pro_key  { pro  $http_x_api_key; default ""; }

limit_req_zone $ip_key   zone=per_ip:10m      rate=20r/s;
limit_req_zone $free_key zone=tenant_free:10m rate=5r/s;
limit_req_zone $pro_key  zone=tenant_pro:10m  rate=50r/s;

server {
    listen 80;
    limit_req_status    429;   # not 503: the server is healthy, the client is too fast
    limit_req_log_level warn;  # delayed requests log one level lower (notice)

    location /api/ {
        # All three apply; the most restrictive one rejects first.
        limit_req zone=per_ip      burst=40  nodelay;
        limit_req zone=tenant_free burst=10  nodelay;
        limit_req zone=tenant_pro  burst=100 nodelay;
        error_page 429 @rate_limited;
        proxy_pass
    }

    location @rate_limited {
        default_type application/json;
        add_header Retry-After 1 always;
        return 429 '{"error":"rate_limited","message":"Too many requests, retry after the Retry-After interval."}\n';
    }
}

An nginx configuration that recovers the client IP with realip, exempts internal ranges and selects a tier zone by emitting empty keys from geo and map, stacks per-IP and per-tenant limits, and returns a JSON 429 with Retry-After.

Testing and rolling out safely

Before enforcing anything, confirm that the zone, key and burst values behave the way you expect. Point a load generator such as ApacheBench (ab) or a plain curl loop at a staging host, and send traffic both below and above the configured rate. Test from more than one source address so you can confirm the key separates clients correctly. A single test client hides keying mistakes.

Read the logs, not just the responses

The error log records rejected requests as "limiting requests" and delayed ones as "delaying request". Both entries name the zone and the excess, so you can see how deep into the burst a client got. Set limit_req_log_level deliberately. Delays are logged one level below rejections, so a warn threshold can hide them entirely. In the access log, count status codes per endpoint and confirm that rejections carry the code you chose with limit_req_status rather than the default 503.

Dry run first

Newer Nginx releases provide limit_req_dry_run. It evaluates the limit and logs what would have happened, but lets every request through. Some versions also expose a $limit_req_status variable that you can add to the access log format. Check the documentation for the version you run before relying on either feature.

A safe rollout has three stages:

  1. Run in dry-run mode against production traffic.
  2. Enforce with deliberately generous limits.
  3. Tighten in steps, checking the logs after each change.

Real traffic exposes shared NAT gateways, batch jobs and health checkers that a staging test never will. Finding them in a log line is cheaper than finding them through a customer-facing incident and a rollback.

When to avoid limit_req and its operational trade-offs

The limit is per instance

Each Nginx instance keeps its zone in its own shared memory, and instances do not share state. Behind a load balancer with four instances, a rate=10r/s limit lets a client send up to 40 requests per second if its traffic spreads evenly. To get a predictable global limit, either pin clients to one instance (consistent hashing on the same key) or divide the configured rate by the instance count. Dividing breaks when autoscaling changes the count, so the effective limit drifts unless the config is regenerated.

Queueing and burst costs

burst without nodelay holds excess requests inside Nginx until their slot arrives. Each one keeps a client connection open and adds latency the client may not tolerate. A client that times out and retries makes the queue longer. nodelay removes the wait but forwards the entire burst at once, so the backend must absorb burst + 1 simultaneous requests per key, not just the steady rate.

What it doesn't measure

limit_req counts requests, not cost. A cheap health check and an expensive report export consume the same token. If request cost varies by orders of magnitude, limit by endpoint or enforce quotas in the application.

Not a DDoS defense

A rejected request still costs Nginx a connection, often a TLS handshake, and header parsing before the 503 goes out. Volumetric floods saturate bandwidth or CPU before limit_req runs. Filter that traffic upstream of Nginx.

Alternatives and impact on cost and engineering time

Each alternative below covers a gap that limit_req leaves open.

Mechanism What it limits Best fit Main cost
limit_conn Concurrent connections per key Slow uploads, long downloads, WebSockets Config only; does not bound request rate
Application-level quotas Weighted units per account or plan Expensive endpoints, billing tiers, monthly caps Code, storage, tests, ongoing ownership
Shared-state limiter (app or gateway) Request rate across all nodes Exact cluster-wide limits Extra network round trip per request; a dependency that can fail
Network or edge filtering Packets, bandwidth, connection floods Volumetric attacks Runs outside Nginx; coarse per-client control

Adding limit_req takes a few lines of config and a reload, which is why teams reach for it first. Most of its cost comes from mistakes. If you key on $binary_remote_addr behind a load balancer, every client shares one address and one limit. If you set burst too low, normal page loads with parallel fetches return 503s. Both problems tend to show up as support tickets and incident time, not as alerts.

Each Nginx instance keeps its own zone, so limits are per node.

Illustrative estimate (assumed numbers, not a benchmark): effective maximum rate ≈ configured rate × number of instances. With an assumed 10 r/s and 4 instances behind round-robin, one client can reach about 40 r/s.

If that error is acceptable, stay with configuration. An application-level or shared-state limiter is worth building and maintaining in three cases:

  • The limit is contractual or affects billing.
  • Requests differ widely in cost.
  • The key depends on identity that Nginx cannot see.

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 burst nodelay, 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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.