Rate Limiting Algorithms with Redis
redis distributed-systems api-design backend security
Why do we need?
• Prevents overload → avoids crashes during traffic spikes
• Stops abuse → protects against bots, brute force, DDoS
• Ensures fairness → one user can’t hog all resources
• Controls cost → limits excessive resource usage
• Stabilizes performance → smoother response times
👉 In short: it keeps the system secure, stable, and fair
Lets talk about 3 widely used techniques
Fixed Window
Imagine a box that resets every minute. You get 10 tokens in that box. Every request uses one. Once empty, you’re blocked until the next minute starts.
Simple, but has a flaw: if you use all 10 in the last second of minute 1, and 10 more in the first second of minute 2, you’ve made 20 requests in 2 seconds - the window boundary can be gamed.
Sliding Window
Like Fixed Window, but instead of resetting on a clock tick, it looks backwards from right now. “How many requests have you made in the last 60 seconds?” No boundary exploit, but uses more memory (stores timestamps per request).
Token Bucket
You have a bucket that fills with tokens at a steady rate (say, 1 per second). Each request costs a token. If the bucket is full, new tokens are discarded. If empty, you wait. This is the most flexible - it allows short bursts while still enforcing a long-term rate.
Redis Data Structures Used
| Algorithm | Redis Structure | Why |
|---|---|---|
| Fixed Window | String (counter) | Just a number + TTL. Dead simple. |
| Sliding Window | Sorted Set | Stores timestamps, auto-removes old ones. |
| Token Bucket | Hash | Stores tokens + lastRefill together. |
Trade-Off Comparison
| Fixed Window | Sliding Window | Token Bucket | |
|---|---|---|---|
| Burst allowed? | At boundary only | No - always strict | Yes - up to bucket capacity |
| Best for | Simple internal APIs | APIs needing hard fairness | Services that tolerate short bursts |
| Penalizes idle users? | No | No | No - rewards them with saved tokens |
| Complexity | Low | Medium | Medium |
| Redis structure | String + TTL | Sorted Set | Hash |
| Atomic by default? | No - needs Lua | No - needs Lua | No - needs Lua |
| Memory usage | Very low | High (stores all timestamps) | Low (just 2 fields) |
| Accuracy | Low - boundary exploit possible | High - no boundary gaps | Medium - burst can skew rate |
Fixed Window is the only one with a known exploit - the boundary burst problem. Sliding Window was invented to fix that flaw, and Token Bucket trades some accuracy for flexibility.
Which One Should You Use?
Simple rule - just ask one question: “Does it matter if someone sneaks in extra requests?”
-
If you just need something quick and simple → Fixed Window. Internal tools, admin dashboards, low-stakes APIs.
-
If fairness is critical, no exceptions → Sliding Window. Payment APIs, auth endpoints, anything where every extra request is a real problem.
-
If your users have natural idle periods → Token Bucket. Mobile apps, search, feed APIs. Users deserve a burst after being inactive.
Token Bucket is the safe default for most consumer-facing APIs because it feels fair to users without being dangerously loose.
Implementation
const Redis = require("ioredis");
const redis = new Redis();
Fixed Window
async function fixedWindow(userId, limit = 10, windowSec = 60) {
const key = `fw:${userId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSec);
}
const ttl = await redis.ttl(key);
if (current > limit) {
return {
allowed: false,
message: `Rate limit exceeded. Try again in ${ttl}s.`,
count: current,
limit,
ttl,
};
}
return {
allowed: true,
message: `Request allowed (${current}/${limit})`,
count: current,
limit,
ttl,
};
}
Sliding Window
async function slidingWindow(userId, limit = 10, windowMs = 60_000) {
const key = `sw:${userId}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, "-inf", windowStart);
pipeline.zcard(key);
pipeline.zadd(key, now, `${now}-${Math.random()}`);
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results[1][1];
if (count >= limit) {
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
message: `Rate limit exceeded. ${count} requests in last ${windowMs / 1000}s.`,
count,
limit,
};
}
return {
allowed: true,
message: `Request allowed (${count + 1}/${limit})`,
count: count + 1,
limit,
};
}
Token Bucket
async function tokenBucket(userId, capacity = 10, refillRate = 1) {
const key = `tb:${userId}`;
const data = await redis.hgetall(key);
const now = Date.now() / 1000;
let tokens = data.tokens ? parseFloat(data.tokens) : capacity;
let lastRefill = data.lastRefill ? parseFloat(data.lastRefill) : now;
const elapsed = now - lastRefill;
const refilled = elapsed * refillRate;
tokens = Math.min(capacity, tokens + refilled);
lastRefill = now;
if (tokens < 1) {
await redis.hset(key, "tokens", tokens.toFixed(4), "lastRefill", lastRefill);
await redis.expire(key, capacity * 2);
const waitSec = ((1 - tokens) / refillRate).toFixed(2);
return {
allowed: false,
message: `No tokens left. Wait ~${waitSec}s for next token.`,
tokens: parseFloat(tokens.toFixed(4)),
capacity,
};
}
tokens -= 1;
await redis.hset(key, "tokens", tokens.toFixed(4), "lastRefill", lastRefill);
await redis.expire(key, capacity * 2);
return {
allowed: true,
message: `Request allowed. Tokens remaining: ${tokens.toFixed(2)}`,
tokens: parseFloat(tokens.toFixed(2)),
capacity,
};
}
Demo
async function demo() {
const userId = "user_anshuman";
console.log("=== Fixed Window ===");
for (let i = 0; i < 12; i++) {
const result = await fixedWindow(userId, 5, 60);
console.log(`Request ${i + 1}:`, result.message);
}
console.log("\n=== Sliding Window ===");
for (let i = 0; i < 7; i++) {
const result = await slidingWindow(userId, 5, 60_000);
console.log(`Request ${i + 1}:`, result.message);
}
console.log("\n=== Token Bucket ===");
for (let i = 0; i < 12; i++) {
const result = await tokenBucket(userId, 5, 1);
console.log(`Request ${i + 1}:`, result.message);
}
redis.disconnect();
}
demo().catch(console.error);
Watch Out: The Race Condition
One subtle race condition to be aware of - if your process crashes between incr and expire, the key gets created but never gets an expiry. The production fix is a Lua script:
-- KEYS[1] = rate limit key, e.g. "fw:user123"
-- ARGV[1] = limit
-- ARGV[2] = window in seconds
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
local ttl = redis.call("TTL", key)
local allowed = 0
if current <= limit then
allowed = 1
end
return { allowed, current, limit, ttl }
Node.js example with EVAL:
async function fixedWindow(userId, limit = 10, windowSec = 60) {
const key = `fw:${userId}`;
const [allowedRaw, count, max, ttl] = await redis.eval(
luascript,
1,
key,
limit,
windowSec
);
const allowed = allowedRaw === 1;
return {
allowed,
message: allowed
? `Request allowed (${count}/${max})`
: `Rate limit exceeded. Try again in ${ttl}s.`,
count,
limit: max,
ttl,
};
}
Comments
Comments could not be loaded.
Be the first to comment!