Implementing Rate-Limiting with Redis Correctly
AI generated
SET
TTL
Redis · API Security · Lua Scripting · Backend
Implementing Rate-Limiting with Redis Correctly
from INCR to the atomic token bucket

An API endpoint without rate-limiting is an open invitation for abuse, whether from misbehaving clients, aggressive scrapers or targeted attacks. Redis provides INCR, EXPIRE and Lua scripts as the building blocks for fixed window, sliding window and token bucket limiters that stay correct and atomic even under heavy load.

18 min read INCR · EXPIRE · Lua · Sorted Sets Redis 6.x · 7.x

1. Why rate-limiting belongs in Redis

Rate-limiting restricts how often a client can perform an action within a time window, and it is one of the few measures that address abuse and accidental overload at the same time. The challenge is not the idea, it is the implementation: counters must stay consistent across many application servers, correctly count concurrent access, and still respond in microseconds because every request has to pass through the limiter before the actual logic starts.

Redis is better suited for rate-limiting than a relational database because operations like INCR are atomic, TTLs are supported natively, and typical latency sits in the sub-millisecond range. A counter in Postgres needs a transaction with a row lock, a counter in Redis needs a single roundtrip. At thousands of requests per second, this difference is the difference between a limiter that keeps up and one that becomes the bottleneck itself.

The following sections build three established algorithms for rate-limiting with Redis: fixed window, sliding window and token bucket. Each has different trade-offs between implementation effort, memory footprint and accuracy at window boundaries, and the choice should be made deliberately based on the actual traffic pattern.

2. Fixed window counter with INCR and EXPIRE

The simplest approach to rate-limiting is the fixed window counter: a key is incremented per client and time window, and the first INCR of a new window sets an EXPIRE matching the window length. If the counter exceeds the limit, the request is rejected. The key typically carries a rounded timestamp in its name, for example ratelimit:api-key-42:2026072314 for a one hour window, so each window automatically produces a new key.

The advantage of this rate-limiting pattern lies in its simplicity: two Redis commands, one key per client and window, minimal memory footprint since expired windows are removed automatically via TTL. For many internal APIs and simple public APIs this approach is entirely sufficient, especially when some inaccuracy at window boundaries is tolerable.


# Fixed window rate-limiting via redis-cli
# Window: 60 seconds, limit: 100 requests

redis-cli> INCR ratelimit:client-42:window:29140805
(integer) 1
redis-cli> EXPIRE ratelimit:client-42:window:29140805 60 NX
(integer) 1
redis-cli> INCR ratelimit:client-42:window:29140805
(integer) 2

# Application logic: reject when counter exceeds limit
# window_id = floor(unix_timestamp / 60)
# key = "ratelimit:" + client_id + ":window:" + window_id

3. The boundary problem of the fixed window

The key drawback of the fixed window counter shows up at the window boundary: a client can exhaust the full limit right before a window ends and exhaust the full limit again right after the next window begins. With a limit of 100 requests per minute, a two second span around the boundary could theoretically let through 200 requests, even though rate-limiting per minute should only allow 100. For many use cases this is acceptable, for systems with tight capacity or billing relevance it is not.

This behavior is not an implementation bug, it is a structural property of fixed time windows. Anyone who cannot tolerate this weakness in rate-limiting needs to switch to an algorithm that has no hard window boundaries and instead counts continuously over a moving time span. That is exactly what sliding window approaches provide, built in the next section.

4. Sliding window log with sorted sets

The sliding window log stores every single request as an entry in a sorted set per client, where the score is the request's unix timestamp. Before each new request, ZREMRANGEBYSCORE removes all entries older than the time window, and ZCARD returns the number of remaining entries. If this count is below the limit, the current request is added via ZADD, otherwise it is rejected. This rate-limiting approach is exact because it actually tracks every request individually instead of rounding into windows.

The price for this exactness is memory usage: at a limit of 1000 requests per hour, a sorted set with up to 1000 entries must be kept per active client. Across millions of active clients this quickly adds up to substantial RAM usage, which is why the sliding window log is mainly suited to endpoints with a low limit or a small client count, such as login attempts or expensive write operations.


-- sliding_window_log.lua
-- KEYS[1] = rate limit key, ARGV[1] = now (ms), ARGV[2] = window (ms), ARGV[3] = limit
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- Drop entries outside the sliding window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

local count = redis.call('ZCARD', key)
if count < limit then
  redis.call('ZADD', key, now, now .. '-' .. math.random())
  redis.call('PEXPIRE', key, window)
  return 1 -- allowed
else
  return 0 -- rejected
end

5. Sliding window counter as a compromise

The sliding window counter combines the accuracy of the log with the small memory footprint of the fixed window counter by combining two adjacent fixed window counters with a weighted formula. The formula is: current count plus previous count multiplied by the fraction of the previous window that still falls within the sliding window. In a one minute window with a request 15 seconds after window start, the previous window contributes 75 percent of its weight, because 45 of its 60 seconds are still relevant. This rate-limiting approach approximates a true sliding window using only two counters per client.

The approximation is accurate enough in practice because traffic is rarely perfectly uniform, and the deviation from a true sliding window calculation typically stays in the low single digit percent range. For most public APIs, the sliding window counter is the best compromise between accuracy at window boundaries and memory efficiency, which is why many commercial API gateways use it as their default algorithm.

6. Token bucket for burst tolerance

The token bucket differs conceptually from the previous approaches because it does not count requests within windows, instead it manages a balance of tokens that refills at a constant rate. Each bucket has a maximum capacity and a refill rate, for example ten tokens per second with a capacity of 50. Each request consumes one token, and if the bucket is empty the request is rejected or delayed. Because unused capacity accumulates up to the maximum, the token bucket allows short bursts while the long term average rate stays bounded.

In Redis, the token bucket is implemented using a hash with the fields tokens and last_refill. On each request, the script calculates how many tokens should have flowed in since the last refill, adds them up to the capacity limit, and then deducts one token. This rate-limiting model is particularly suited to APIs where short term spikes are legitimate, for example when a user loads several resources in quick succession, but the average usage should still be bounded.


-- token_bucket.lua
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill_rate (tokens/sec), ARGV[3] = now (sec), ARGV[4] = requested tokens
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens >= requested then
  tokens = tokens - requested
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
  redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
  return 1 -- allowed
else
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
  redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
  return 0 -- rejected
end

7. Enforcing atomicity with Lua scripts

All the multi-command examples above share a problem when executed without Lua: between reading the counter and increasing it, another process can modify the same key, causing race conditions. Redis executes Lua scripts atomically, meaning no other client can inject commands between the lines of a script. For correct rate-limiting under real concurrency, Lua scripts are therefore not an optimization, they are a necessity as soon as more than one Redis command is needed for the decision.

A common mistake is running INCR and EXPIRE as two separate roundtrips from the application server. Between the two calls the process can crash or the connection can drop, leaving the key without a TTL in memory, never to expire. The robust solution sets EXPIRE with the NX option only on the first INCR of a window, either via a small Lua script or via a MULTI transaction with WATCH, with the Lua script usually being the better choice due to lower overhead.


-- fixed_window_atomic.lua
-- KEYS[1] = rate limit key, ARGV[1] = window seconds, ARGV[2] = limit
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
  return 0 -- rejected
end
return 1 -- allowed

8. Per-IP and per-API-key limiting

The choice of rate limit key decides which layer of rate-limiting is actually protected. Per-IP limiting with the key pattern ratelimit:ip:203.0.113.42:60 protects against volumetric attacks from a single source, but fails against distributed attacks and hits legitimate users behind a shared NAT gateway too hard. Per-API-key limiting with ratelimit:key:sk_live_abc123:60 is more precise because it is tied to an authenticated identity, and it additionally allows different limits depending on a customer's plan tier.

In practice both layers are combined: a generous per-IP limit as a coarse line of defense against bots without a valid key, and a tight per-API-key limit for authenticated requests that reflects the actual business logic of rate-limiting. PHP implementations usually attach this logic as middleware in front of the route, so the actual application logic does not need to know about the limiting.


<?php
declare(strict_types=1);

final class RedisRateLimiter
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $luaScript
    ) {
    }

    /**
     * Checks whether the request identified by $key is allowed
     * under the given window and limit using an atomic Lua script.
     */
    public function isAllowed(string $key, int $windowSeconds, int $limit): bool
    {
        $result = $this->redis->eval(
            $this->luaScript,
            ["ratelimit:{$key}:{$windowSeconds}"],
            1
        );

        return (bool) $result;
    }
}

// Usage: per-IP and per-API-key combined
$limiter = new RedisRateLimiter($redis, $fixedWindowScript);
if (!$limiter->isAllowed("ip:{$clientIp}", 60, 300)) {
    http_response_code(429);
    exit;
}
if (!$limiter->isAllowed("key:{$apiKey}", 60, 100)) {
    http_response_code(429);
    exit;
}
Algorithm Memory footprint Boundary accuracy Best fit
Fixed window Very low Weak, up to 2x burst Internal APIs, coarse limits
Sliding window log High, one entry per request Exact Login attempts, expensive writes
Sliding window counter Low, two counters Very good, approximated Public APIs, default choice
Token bucket Low, one hash Good, allows bursts Burst tolerant APIs

9. Scaling, failure modes and header design

A production rate-limiting system must still make a decision even when Redis itself is unreachable. The choice between fail-open, letting requests through on a Redis outage, and fail-closed, rejecting requests, is a deliberate architecture decision. Fail-open protects availability at the cost of security, fail-closed protects against abuse at the cost of availability. For most public APIs, fail-open with a short timeout, around 20 milliseconds, is the better choice, because a Redis outage rarely lasts longer than a few seconds and completely blocked traffic causes greater damage.

In Redis cluster environments, the rate limit key should include the client identity as a hash tag, for example ratelimit:{ip:203.0.113.42}:60, so all keys of a client land on the same shard and Lua scripts do not fail across shard boundaries. For communicating with the client, rate-limiting should always include the standard headers X-RateLimit-Limit, X-RateLimit-Remaining and Retry-After on a 429 response, so client libraries can automatically apply exponential backoff instead of immediately hammering the endpoint again.

10. Summary

Rate-limiting with Redis is not a single algorithm, it is a family of solutions with different trade-offs. The fixed window counter is the simplest but tolerates bursts at window boundaries. The sliding window log is exact but costs memory proportional to the number of requests. The sliding window counter approximates that accuracy with minimal memory footprint and is the right choice for most public APIs. The token bucket allows controlled bursts within a bounded average rate and fits use cases with naturally uneven traffic.

What matters most for correct rate-limiting under load is atomicity of the operations in every case: Lua scripts avoid race conditions between reading and writing and reduce the number of network roundtrips to a single one. Combined with well thought out key design for per-IP and per-API-key limits, plus a clear failure mode strategy, the result is a limiter that behaves predictably both under normal operation and during Redis outages.

Rate-limiting with Redis, the essentials at a glance

Choose the algorithm

Fixed window for simple cases, sliding window counter as default, token bucket for burst tolerance.

Enforce atomicity

Lua scripts for INCR plus EXPIRE or token calculation, otherwise race conditions loom under load.

Key design

Per-IP as coarse defense, per-API-key for precise business logic, hash tags in the cluster.

Clarify failure mode

Fail-open with a short timeout for most public APIs, Retry-After header on 429.

11. FAQ: Rate-Limiting with Redis

1Which algorithm suits most APIs?
The sliding window counter is the best compromise between accuracy and memory footprint for most public APIs.
2Why are Lua scripts important?
Atomic execution prevents race conditions between reading and writing the counter under load.
3What is the boundary problem?
At window boundaries up to twice as much traffic can pass through as intended.
4How much memory does the sliding window log need?
Proportional to the number of allowed requests per client, since each request is stored individually.
5Fail-open or fail-closed on a Redis outage?
Fail-open with a short timeout is recommended for most public APIs.
6How do I combine per-IP and per-API-key?
Generous IP limit as coarse defense, tighter key limit for the business logic.
7What makes the token bucket special?
Unused capacity accumulates and allows short term bursts within a bounded average rate.
8Which HTTP headers belong here?
X-RateLimit-Limit, X-RateLimit-Remaining and Retry-After on a 429 response.
9How does limiting work in a Redis cluster?
Hash tags in the key ensure related keys land on the same shard.
10Why isn't INCR without EXPIRE-NX enough?
A crash between the two commands can leave a key without a TTL that never expires.