Redis Rate Limiting — Complete Implementation Guide
In this tutorial, you will learn about Redis Rate Limiting. We cover key concepts, practical examples, and best practices to help you master this topic.
Redis rate limiting leverages Redis atomic operations, TTL-based expiry, and Lua scripting to implement fast, accurate, and distributed rate limiting that scales across multiple application servers.
What You'll Learn
By the end of this tutorial, you will implement Redis-based rate limiting using INCR, sorted sets, and Lua scripts, and understand when to use each pattern.
Why It Matters
In-memory rate limiting breaks in multi-server deployments. Redis provides a shared, atomic counter store that all application instances can use consistently.
Real-World Use
DodaTech's Microservices all share a single Redis cluster for rate limiting, ensuring consistent limits regardless of which service instance handles a request.
Redis Rate Limiting Learning Path
flowchart LR
A[Express Rate Limiting] --> B[Redis Rate Limiting]
B --> C[INCR + EXPIRE]
B --> D[Sorted Sets]
B --> E[Lua Scripts]
B --> F{You Are Here}
style F fill:#f90,color:#fff
INCR with EXPIRE Pattern
The simplest Redis rate limiting pattern uses INCR to increment a counter and EXPIRE to set a TTL, creating an automatic fixed window.
const Redis = require("ioredis");
const redis = new Redis();
async function incrRateLimit(req, res, next) {
const key = `ratelimit:${req.ip}:${Math.floor(Date.now() / 60000)}`;
const max = 10;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 120);
}
res.setHeader("X-RateLimit-Limit", max);
res.setHeader("X-RateLimit-Remaining", Math.max(0, max - count));
res.setHeader("X-RateLimit-Reset", Math.floor(Date.now() / 60000) * 60000 + 60000);
if (count > max) {
return res.status(429).json({ error: "Rate limit exceeded" });
}
next();
}
Expected behavior: Each minute window has a counter in Redis. Atomic INCR ensures accuracy. TTL of 120 seconds cleans up automatically.
Sorted Set Sliding Window
For precise sliding window rate limiting, Redis sorted stores timestamps as scores, enabling range queries to count requests in any time window.
async function sortedSetLimiter(req, res, next) {
const key = `ratelimit:ss:${req.ip}`;
const windowMs = 60000;
const max = 10;
const now = Date.now();
const member = `${req.ip}:${now}:${Math.random()}`;
const count = await redis.zcount(key, now - windowMs, "+inf");
if (count >= max) {
const oldest = await redis.zrange(key, 0, 0, "WITHSCORES");
const retryAfter = Math.ceil((parseInt(oldest[1]) + windowMs - now) / 1000);
return res.status(429).json({
error: "Too many requests",
retryAfter
});
}
await redis.multi()
.zadd(key, now, member)
.zremrangebyscore(key, 0, now - windowMs)
.expire(key, Math.ceil(windowMs / 1000) + 1)
.exec();
next();
}
Lua Script for Atomic Operations
Lua scripts ensure multiple Redis operations execute atomically, preventing race conditions in high-concurrency scenarios.
-- rate_limit.lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
local member = ARGV[4]
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count < max then
redis.call("ZADD", key, now, member)
redis.call("EXPIRE", key, math.ceil(window / 1000))
return {1, count + 1}
else
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
return {0, count, oldest[2]}
end
async function luaLimiter(req, res, next) {
const key = `ratelimit:lua:${req.ip}`;
const now = Date.now();
const member = `${now}:${Math.random().toString(36).slice(2)}`;
const result = await redis.eval(
script, 1, key, now, 60000, 10, member
);
if (result[0] === 0) {
const retryAfter = Math.ceil((parseInt(result[2]) + 60000 - now) / 1000);
return res.status(429).json({
error: "Rate limit exceeded",
retryAfter
});
}
next();
}
Common Mistakes
Not setting EXPIRE on keys -- Without TTL, Redis memory fills with stale keys. Always set EXPIRE after INCR for fixed window.
Using non-atomic operations -- GET, check, SET in separate commands creates race conditions. Use INCR, Lua, or MULTI/EXEC.
Hardcoding Redis connection details -- Use environment variables or a configuration service for Redis connection parameters.
Not handling Redis connection failures -- When Redis is down, rate limiting stops. Implement fallback logic or fail open.
Creating too many unique keys -- Each unique client creates a Redis key. Millions of keys consume memory. Use appropriate key expiry.
Practice Questions
Why is INCR preferred over GET + SET for rate limiting? INCR is atomic. GET + SET has a Race Condition where two requests can read the same value.
How does sorted set sliding window avoid storing all timestamps indefinitely? The ZREMRANGEBYSCORE command removes entries outside the window on every request.
What happens if a Lua script takes too long? Redis blocks all other operations while the script runs. Keep Lua scripts fast and avoid infinite loops.
Challenge: Implement a rate limiter that distinguishes between burst and sustained limits using Redis.
// Allow a burst of 20 requests, then 10 requests per minute
const burstKey = `burst:${req.ip}`;
const sustainedKey = `sustained:${req.ip}`;
const burstCount = await redis.get(burstKey);
if (burstCount < 20) {
await redis.incr(burstKey);
await redis.expire(burstKey, 60);
} else {
// Enforce sustained limit
}
FAQ
Mini Project
Build a complete Redis rate limiting system with Lua scripts, key management, connection pooling, and graceful degradation.
const Redis = require("ioredis");
class RedisRateLimiter {
constructor(opts) {
this.redis = new Redis({
host: opts.redisHost || "localhost",
port: opts.redisPort || 6379,
enableOfflineQueue: false,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
this.script = `
local k = KEYS[1]
local n = tonumber(ARGV[1])
local w = tonumber(ARGV[2])
local m = tonumber(ARGV[3])
local mb = ARGV[4]
redis.call("ZREMRANGEBYSCORE", k, 0, n - w)
local c = redis.call("ZCARD", k)
if c < m then
redis.call("ZADD", k, n, mb)
redis.call("EXPIRE", k, math.ceil(w / 1000) + 1)
return {1, c + 1, m}
end
return {0, c, m}
`;
}
async check(key, opts) {
try {
const now = Date.now();
const member = `${now}:${Math.random().toString(36).slice(2)}`;
const result = await this.redis.eval(
this.script, 1,
`ratelimit:${key}`,
now, opts.windowMs || 60000,
opts.max || 100,
member
);
return { allowed: result[0] === 1, current: result[1], limit: result[2] };
} catch (err) {
console.error("Rate limiter error:", err.message);
return { allowed: true, current: 0, limit: opts.max || 100 };
}
}
}
What's Next
Now that you understand Redis rate limiting, explore rate limiting across multiple servers. Then learn about proper rate limit HTTP headers.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro