The rate limiter I wrote about previously works correctly. One Redis node, sliding window, proper atomicity via pipeline, no fixed-window burst holes. I was satisfied with it.
Then the service needed to scale. Multiple Redis nodes. Suddenly the "correct" implementation was producing wrong counts, and limits that should have blocked users were letting them through.
This post is what I learned designing a rate limiter that actually works across a distributed Redis setup.
Why You Eventually Need More Than One Redis Node
Single Redis node rate limiting breaks in two ways before you even think about correctness:
Memory. Redis stores everything in RAM. A sliding window rate limiter using sorted sets stores one entry per request per user for the duration of the window. At 100K users each with 60 requests per minute, that's 6 million sorted set members in RAM at all times. Redis sorted sets have overhead per member. You run out of memory before you run out of CPU.
Single point of failure. Your rate limiter is now in the critical path of every API request. If your one Redis node goes down, you either block all traffic (fail closed) or allow all traffic (fail open). Neither is great. Fail open means your rate limiter vanishes silently during an outage. Fail closed means Redis downtime kills your API.
So you add Redis nodes. And that is where the correctness problems start.
The Counting Problem
Here is the specific failure mode. You have two Redis nodes. Your rate limit is 10 requests per minute per user.
User sends request 1: hits Node A. Count in Node A: 1. Allowed. User sends request 2: hits Node B (different node, different hash, or just load balanced). Count in Node B: 1. Allowed. ... User sends request 10 via Node A: count in A is 5. Allowed. User sends request 11 via Node B: count in B is 5. Allowed.
The user just made 11 requests. Both nodes think the count is 5. Neither has seen more than 6 requests. Your 10 request limit is now a 20 request limit.
This is not a race condition. It is a fundamental property of having state split across nodes with no coordination.
Solution 1: Redis Cluster with Key Hashing
Redis Cluster assigns each key to one of 16,384 slots using CRC16 hashing. Every key always goes to the same node. Your rate limit key ratelimit:user:42 hashes to slot 7823, which lives on Node 2. Every request for user 42 hits Node 2. No split counting.
import { createCluster } from "redis";
const cluster = createCluster({
rootNodes: [
{ url: "redis://node1:6379" },
{ url: "redis://node2:6379" },
{ url: "redis://node3:6379" },
],
});
await cluster.connect();
async function isRateLimited(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
const key = `ratelimit:user:${userId}`; // always routes to same node via CRC16 hash
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
// Pipeline is atomic within a single node
const results = await cluster.multi()
.zRemRangeByScore(key, 0, windowStart)
.zCard(key)
.zAdd(key, { score: now, value: `${now}-${Math.random()}` })
.expire(key, windowSeconds * 2)
.exec();
const count = results[1] as number;
return count >= limit;
}
This works because the key is always on the same node. The pipeline (multi/exec) is atomic within that node.
The problem: Redis Cluster does not support multi-key operations across slots. If you ever need to check multiple users or do cross-user operations in a pipeline, you cannot. Each key is on a different node and cannot participate in the same transaction.
Hash Tags: Keeping Related Keys Together
Redis Cluster uses the substring inside {} as the hash key if present. This lets you force related keys to the same slot.
// Without hash tags: user data might be on different nodes
const sessionKey = `session:${userId}`; // slot X
const rateLimitKey = `ratelimit:${userId}`; // slot Y (different node)
// With hash tags: both keys hash on userId, guaranteed same slot
const sessionKey = `{${userId}}:session`;
const rateLimitKey = `{${userId}}:ratelimit`;
// Now you can pipeline these together
await cluster.multi()
.get(`{${userId}}:session`)
.zCard(`{${userId}}:ratelimit`)
.exec();
If you need to read both the session and the rate limit count atomically, hash tags make it possible.
Solution 2: Lua Scripts for Atomicity
The pipeline approach (MULTI/EXEC) in my original implementation has a subtle issue: between zRemRangeByScore removing old entries and zCard counting the result, another request can sneak in. With Redis pipelines, commands are queued client-side and sent in one batch, but they are not truly atomic.
Lua scripts in Redis run atomically. The entire script executes as a single Redis command with no interruption.
const slidingWindowLua = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_start = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local window_seconds = tonumber(ARGV[4])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
-- Count current entries
local count = redis.call('ZCARD', key)
if count >= limit then
return 0 -- rate limited
end
-- Add this request
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('EXPIRE', key, window_seconds * 2)
return 1 -- allowed
`;
async function isRateLimited(client: RedisClientType, userId: string, limit: number, windowSeconds: number): Promise<boolean> {
const key = `ratelimit:user:${userId}`;
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const result = await client.eval(slidingWindowLua, {
keys: [key],
arguments: [now.toString(), windowStart.toString(), limit.toString(), windowSeconds.toString()],
});
return result === 0; // 0 = rate limited, 1 = allowed
}
The Lua script runs atomically on whatever node owns the key. No window between count and add. No other request can observe a partial state.
Redis guarantees that Lua scripts are atomic. No other command can run on the Redis instance while a script executes. This is different from MULTI/EXEC which can be interrupted by WATCH/UNWATCH in optimistic locking patterns.
Solution 3: The Approximation Approach
If you do not need exact limits and can tolerate 5-10% over-counting, you can skip coordination entirely and accept eventual consistency across nodes.
Each node maintains its own count. Periodically (every few seconds), nodes sync their counts to a shared store or to each other. When checking a limit, each node checks its local count plus a recent sync'd aggregate.
This is how Cloudflare's rate limiting works at their scale. Exact counts across thousands of edge nodes are impossible without coordination. Approximate counts with small error windows are acceptable for most use cases.
For application-level rate limiting (not edge CDN), this is overkill. Stick with Redis Cluster and Lua scripts.
What Breaks at the Network Level
Even with correct distributed state, network partitions cause problems.
Scenario: your Redis node is unreachable for 200ms. What does your rate limiter do?
async function isRateLimited(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
try {
// ... redis operations
} catch (error) {
if (isRedisConnectionError(error)) {
// Fail open: allow request, Redis is down
return false;
// Or fail closed: block request, Redis is down
// return true;
}
throw error;
}
}
Fail open during Redis downtime: your limits do not apply during the outage. An attacker who knows your Redis is flaky can target the downtime window.
Fail closed during Redis downtime: your API goes down whenever Redis does. Tight coupling.
The right answer depends on what you are rate limiting. Login endpoints: fail closed. Public API endpoints with legitimate traffic: fail open with monitoring and alerting.
A middle ground: local in-memory fallback with a lower limit.
import { LRUCache } from "lru-cache";
const localLimiter = new LRUCache<string, number[]>({ max: 10000 });
async function isRateLimited(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
try {
return await redisRateLimit(userId, limit, windowSeconds);
} catch {
// Redis unavailable: fall back to in-memory with stricter limit
return localRateLimit(userId, Math.floor(limit * 0.5), windowSeconds);
}
}
function localRateLimit(userId: string, limit: number, windowSeconds: number): boolean {
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const key = `ratelimit:${userId}`;
const timestamps = localLimiter.get(key) ?? [];
const recent = timestamps.filter(t => t > windowStart);
recent.push(now);
localLimiter.set(key, recent);
return recent.length > limit;
}
This is not perfect, it is per-instance memory (no coordination across API server instances), it uses half the normal limit to compensate for that, and it clears on restart. But it beats letting an attacker make unlimited requests during a Redis blip.
The Sliding Window vs Token Bucket Decision at Scale
Sliding window tracks exact request timestamps. Memory per user = requests per window. At 100 req/min per user with 1M active users, that is 100M sorted set entries.
Token bucket tracks two values per user: token count and last refill timestamp. Memory per user is constant regardless of request rate.
At scale, token bucket wins on memory. The tradeoff: token bucket allows bursting (a user with a full bucket can make all requests instantly). Sliding window smooths traffic across the window.
// Token bucket in Lua: constant memory, O(1) per request
const tokenBucketLua = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now
local elapsed = (now - last_refill) / 1000 -- seconds
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens < 1 then
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
return 0 -- rate limited
end
redis.call('HMSET', key, 'tokens', tokens - 1, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
return 1 -- allowed
`;
Two fields in a hash. Scales to 10M users in a fraction of the memory that sliding window requires.
The Design Decision Tree
Do you need exact counts?
Yes: Sliding window + Lua script on Redis Cluster
No: Token bucket + Lua script, or approximate counting
Is Redis availability critical?
Yes: Redis Sentinel/Cluster with replicas + in-memory fallback
No: Single node with fail-open on errors
Is this at edge/CDN scale?
Yes: Approximate distributed counting (Cloudflare Workers KV, Durable Objects)
No: Redis Cluster handles it
Is memory a concern (10M+ users)?
Yes: Token bucket (constant memory per user)
No: Sliding window (more accurate, higher memory)
The rate limiter I shipped started as "one Redis node, sliding window, Lua script." When we needed to scale Redis, I moved to Redis Cluster and kept the same Lua script. When we needed fault tolerance, I added the in-memory fallback with halved limits.
Start simple. Add complexity when you have the specific problem that needs it, not before.
Appendix: Full Production Implementation
import { createCluster, RedisClusterType } from "redis";
import { LRUCache } from "lru-cache";
const cluster = createCluster({
rootNodes: [
{ url: process.env.REDIS_NODE_1 },
{ url: process.env.REDIS_NODE_2 },
{ url: process.env.REDIS_NODE_3 },
],
defaults: { socket: { connectTimeout: 3000 } },
});
const localCache = new LRUCache<string, number[]>({ max: 50000 });
const SLIDING_WINDOW_LUA = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_start = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local window_seconds = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
local count = redis.call('ZCARD', key)
if count >= limit then return 0 end
redis.call('ZADD', key, now, now .. math.random())
redis.call('EXPIRE', key, window_seconds * 2)
return 1
`;
export async function checkRateLimit(
userId: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `{${userId}}:rl`;
const now = Date.now();
try {
const result = await cluster.eval(SLIDING_WINDOW_LUA, {
keys: [key],
arguments: [now.toString(), (now - windowSeconds * 1000).toString(), limit.toString(), windowSeconds.toString()],
});
const count = await cluster.zCard(key);
return {
allowed: result === 1,
remaining: Math.max(0, limit - count),
};
} catch {
// Redis unavailable: local fallback with 50% limit
const fallbackLimit = Math.floor(limit * 0.5);
const windowStart = now - windowSeconds * 1000;
const timestamps = (localCache.get(userId) ?? []).filter(t => t > windowStart);
const allowed = timestamps.length < fallbackLimit;
if (allowed) {
timestamps.push(now);
localCache.set(userId, timestamps);
}
return { allowed, remaining: Math.max(0, fallbackLimit - timestamps.length) };
}
}
