log in
consulting hosting industries the daily tools about contact

nginx limit_req vs limit_conn: They Solve Different Problems

Most nginx rate limiting configs I inherit conflate limit_req and limit_conn. They solve fundamentally different problems, and mixing them up will bite you.

Every time I inherit a server config from another dev or firm, I find the same pattern: limit_req and limit_conn bolted on together, usually cargo-culted from a DigitalOcean tutorial, with no clear understanding of what either one is actually doing. They are not redundant. They are not interchangeable. They protect against different attack shapes, and if you misconfigure them you'll either DoS your own legitimate users or leave yourself wide open.

Let me untangle this.

What Each One Actually Does

limit_conn is simple. It limits the number of simultaneous open connections from a single IP (or whatever key you choose). If a client has 5 connections open right now and your limit is 5, the 6th gets a 503. It doesn't care about request rate. It cares about concurrent state.

limit_req is different. It implements a leaky bucket algorithm against request rate. Requests come in, they drain through the bucket at a fixed rate (say, 10 per second), and if they arrive faster than that, they either queue (if you've set a burst) or get dropped immediately. It doesn't care how many connections are open. It cares about throughput over time.

Here's the thing most configs miss: a single HTTP/2 connection can generate hundreds of requests per second. And a slow-loris style attack holds connections open without sending many requests at all. One module does nothing useful against the other's threat.

The Zones, Defined Once

Both modules use shared memory zones defined at the http block level. This is where I see the first round of confusion - people define zones inside server blocks, which technically works but means the zone is only shared across workers for that server context. Define them once, at the top.

http {
    # limit_req zone: key is IP, 10MB shared memory, rate 20 req/sec
    limit_req_zone $binary_remote_addr zone=req_limit:10m rate=20r/s;

    # limit_conn zone: key is IP, 10MB shared memory
    # (no rate here - rate is set at point of use)
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    # ...
}

$binary_remote_addr instead of $remote_addr - use the binary form. It stores IPs in 4 bytes (IPv4) or 16 bytes (IPv6) instead of a string, so your 10MB zone holds roughly 160,000 IPv4 states instead of ~80,000. Small thing, matters under load.

A Config That Actually Makes Sense

Here's how I'd wire this up for a typical Laravel API endpoint. I want two separate protections:

  1. No single IP hammers the endpoint faster than 20 requests/second on average
  2. No single IP holds more than 15 simultaneous connections open
server {
    listen 443 ssl http2;
    server_name api.example.com;

    # Apply rate limiting to all API routes
    location /api/ {
        # Allow bursts up to 40 req, then queue, no delay on burst
        limit_req zone=req_limit burst=40 nodelay;
        limit_req_status 429;

        # Max 15 simultaneous connections per IP
        limit_conn conn_limit 15;
        limit_conn_status 429;

        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Stricter limits on auth endpoints
    location /api/auth/ {
        limit_req zone=req_limit burst=5 nodelay;
        limit_req_status 429;

        limit_conn conn_limit 5;
        limit_conn_status 429;

        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

A few things worth calling out:

  • limit_req_status 429 and limit_conn_status 429 - by default nginx returns 503. That's wrong for rate limiting. 429 is the correct HTTP status for "too many requests" and your clients (including Laravel's HTTP client) can distinguish it from a real server error.
  • burst=40 nodelay - without nodelay, nginx queues burst requests and drains them at the configured rate, which introduces artificial latency for legitimate bursts (think: a user opening your app and firing 15 parallel asset requests). nodelay passes bursts through immediately but still counts them against the bucket. If you're protecting a login endpoint, drop the nodelay - you want that artificial slowdown.
  • Location blocks are matched longest-prefix first by default. /api/auth/ is more specific than /api/ so it wins. Works as written.

The Gotchas That Actually Bit Me

The zone size math. A 10MB zone sounds big. Under a real DDoS with thousands of unique IPs hitting you, you burn through that fast. When the zone is full, nginx either drops requests or passes them depending on version and config. Set your zone sizes based on the number of unique clients you could realistically see, not some tutorial default. I've started using 20m or 50m for anything public-facing.

limit_conn counts per worker, not globally - wait, actually that's the old behavior. Since nginx uses shared memory zones, limit_conn and limit_req state is shared across workers. But I've seen configs using in-memory state (non-shared zones, old nginx versions) where this wasn't the case. Check your nginx version. If you're on anything before 1.7.7, your zones may not be behaving the way you think.

HTTP/2 and limit_conn. With HTTP/2, browsers typically open a single connection and multiplex everything over it. Setting limit_conn too low will kill legitimate browser traffic. I set it tighter for API clients (which control their own connection pooling) and looser or off for browser-facing endpoints. For a healthcare client last year that had a patient portal plus a JSON API on the same nginx instance, I had to use different limit_conn_zone keys for browser routes vs. API routes to avoid choking out Chrome.

X-Forwarded-For and the wrong IP. If you're behind a load balancer or Cloudflare, $binary_remote_addr is the balancer's IP, not the client's. Every connection looks like it's from one IP and you'll rate-limit yourself into oblivion. You need $http_x_forwarded_for or $realip_module in that case. Set up ngx_http_realip_module, define your trusted upstream IPs with set_real_ip_from, set real_ip_header X-Forwarded-For, and then $binary_remote_addr will resolve correctly. This one has burned me twice on platforms that added a CDN layer after initial deployment.

Logging. By default, nginx logs rate-limited requests at the error level with a noisy message. Fine for debugging, terrible for production log volume if you're getting hammered. You can suppress it:

limit_req_log_level warn;
limit_conn_log_level warn;

Or suppress entirely with silent in newer nginx builds. I leave it at warn so I can still catch it in log aggregation without polluting error dashboards.

When I'd Reach for Each One

limit_req is the one you almost always want. If you're worried about:

  • API abuse
  • Credential stuffing on login endpoints
  • Scraping
  • Bots hammering form submissions

Use limit_req. It's rate over time. That's the shape of most automated abuse.

limit_conn is for specific scenarios:

  • Slow-loris attacks (connections held open with trickle data)
  • Websocket endpoints where clients might leak connections
  • File download endpoints where you want to cap concurrent transfers per IP
  • Any endpoint where long-lived connections are the resource you're protecting

For most Laravel API endpoints, I'd deploy limit_req first and add limit_conn only if I had evidence of a connection-holding problem. Running both everywhere is fine as belt-and-suspenders, but size them independently based on what each one is protecting.

When I wouldn't use either: If you have a legitimate B2B API client behind a NAT or corporate proxy, rate-limiting by IP will wreck them. Their whole office looks like one IP. In that case, move rate limiting into your application layer (Laravel's throttle middleware, per API key), not nginx. nginx rate limiting is for anonymous or untrusted traffic. Authenticated, identified clients deserve application-layer limits keyed to their account, not their ISP.

Closing

The two modules have been in nginx for years and the documentation for each is clear if you read it carefully - the problem is that most tutorials treat them as interchangeable synonyms for "rate limiting" and copy-paste both without explaining the tradeoff. Know what connection state vs. request rate means for your specific endpoint, size your zones for real traffic, and fix your upstream IP resolution before you deploy any of this in anger.

Related

Need help shipping something like this? Get in touch.