Rate limiting is one of those things that feels unnecessary until the day your API gets hammered by a buggy client loop or a scraper, and then it feels very necessary indeed. I’ve had both happen. The second time I decided to actually understand the options instead of just slapping something on and hoping for the best.

Why the algorithm matters

Not all rate limiters behave the same way. Two limiters with “100 requests per minute” configured can produce completely different behavior for your clients, depending on how they track and enforce that limit. The differences are most visible at burst time – when a client sends many requests in quick succession.

Token Bucket

The token bucket algorithm imagines a bucket that holds tokens. Tokens accumulate at a fixed rate up to a maximum capacity. Each request consumes one token. If the bucket is empty, the request is rejected or queued.

What this means practically: a client that’s been idle for a while gets to burst. If your bucket capacity is 20 and refill rate is 5 per second, a client that waits 4 seconds can send 20 requests at once. This is often desirable – it’s forgiving of clients that have natural spiky patterns (a user clicking around an app, for example) while still protecting against sustained abuse.

Sliding Window

The sliding window approach tracks actual request timestamps in a rolling time frame. If your window is 60 seconds and the limit is 100, the system counts requests in the 60-second period ending right now – not from the start of the current minute. This prevents the “boundary burst” problem you get with fixed windows, where a client can send 100 requests at 11:59 and another 100 at 12:00.

The downside is memory cost. You’re storing a timestamp per request per client. At scale, this adds up. A common compromise is the sliding window counter, which approximates the sliding window using two fixed windows and some math – cheaper to store, slightly less accurate but good enough for most cases.

Fixed Window

The simplest approach: count requests in the current minute (or hour, or day). Reset at the window boundary. It’s easy to implement and reason about, but that boundary burst problem is real. I wouldn’t use it for anything where timing matters to fairness.

When Nginx is enough

If you’re running an API behind Nginx and your rate limiting needs are straightforward, you probably don’t need a dedicated rate limiting library or service. Nginx’s limit_req module implements the leaky bucket algorithm (similar to token bucket), and it’s solid.

A basic config:

http {
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api_limit burst=20 nodelay;
            limit_req_status 429;
            proxy_pass http://backend;
        }
    }
}

Breaking this down: limit_req_zone defines a shared memory zone (10MB, enough for ~160,000 IP addresses), keyed by IP, with a rate of 10 requests per second. The burst=20 allows short bursts above that rate. nodelay means burst requests are processed immediately rather than queued – without it, Nginx holds them for as long as needed to maintain the average rate, which adds latency.

You can key the zone on something other than IP – for example, a header value like an API key:

limit_req_zone $http_x_api_key zone=key_limit:10m rate=30r/s;

The full options are documented on nginx.org.

Where Nginx falls short: it doesn’t do per-user tiers (different limits for different plans), it can’t share state across multiple Nginx instances cleanly without Redis, and it has no visibility into why a request was limited. For those cases, you want something like a Redis-backed rate limiter in your application layer.

Rate limiting in application code

For anything beyond basic IP throttling, moving the logic into your application makes sense. A simple sliding window in Redis looks roughly like this in pseudocode:

function isAllowed(userId, limit, windowSeconds):
    key = "ratelimit:" + userId
    now = currentTimestampMs()
    windowStart = now - (windowSeconds * 1000)

    MULTI
        ZREMRANGEBYSCORE key 0 windowStart
        ZADD key now now
        ZCARD key
        EXPIRE key windowSeconds
    EXEC

    count = result[2]
    return count <= limit

The sorted set stores request timestamps as both score and member. You clean up old entries, add the new one, count what’s left. It’s atomic via MULTI/EXEC. This is exactly what libraries like rate-limiter-flexible (Node.js) implement under the hood.

External APIs and rate limits you don’t control

It’s worth mentioning the other side of this: when you’re the client, not the server. A lot of APIs – messaging platforms, maps, payment providers – have their own rate limits you have to respect. If you’ve done any payment API integration, you’ve probably hit this already. Most payment gateways throttle API calls per second and per day, sometimes differently for test vs. production environments.

When I’m integrating an external API, I treat rate limit headers (X-RateLimit-Remaining, Retry-After) as first-class response data. If the API sends them, read them and back off accordingly. If it doesn’t – and some older APIs don’t – build in a conservative retry with exponential backoff. Before getting into the details of any specific integration, it helps to think through choosing an API style that fits your use case, since REST, GraphQL, and gRPC have different patterns for handling these limits.

For most small-to-medium projects, Nginx’s limit_req handles the abuse prevention side, and a light Redis-based limiter handles per-user logic. You don’t need a dedicated rate limiting service until your traffic gets complex enough that the overhead justifies it.