Implementing Rate Limiting with Redis
Production-ready rate limiting implementation using Redis with multiple algorithms: fixed window, sliding window, and token bucket.
Implementing Rate Limiting with Redis
Rate limiting is essential for protecting your API from abuse, ensuring fair usage, and maintaining system stability. Here's how to implement production-ready rate limiting using Redis.
Why Redis for Rate Limiting?
Benefits:
- Atomic operations (critical for accuracy)
- Fast in-memory storage
- Built-in TTL (time-to-live)
- Centralized state (works across multiple servers)
- Excellent performance (100k+ ops/sec)
Alternatives:
- In-memory (doesn't work with multiple servers)
- Database (too slow)
- Third-party services (dependency and cost)
Three Rate Limiting Algorithms
1. Fixed Window Counter
How it works:
- Count requests in fixed time windows
- Reset counter at window boundaries
Pros:
- Simple to implement
- Memory efficient
- Predictable behavior
Cons:
- Burst at window boundaries
- Not perfectly fair
Best for:
- Simple rate limits
- Low precision requirements
- Resource-constrained environments
2. Sliding Window Log
How it works:
- Track timestamp of each request
- Count requests in rolling window
Pros:
- Perfectly accurate
- No boundary burst issues
- Fair across time
Cons:
- Higher memory usage
- More complex
Best for:
- Critical APIs
- High precision requirements
- Fairness is important
3. Token Bucket
How it works:
- Bucket fills with tokens over time
- Each request consumes a token
- Burst allowed if tokens available
Pros:
- Handles bursts gracefully
- Smooth rate limiting
- Memory efficient
Cons:
- More complex to implement
- Harder to reason about
Best for:
- APIs with bursty traffic
- Flexible rate limiting
- Better UX
Implementation
Fixed Window Counter
import { Redis } from 'ioredis';
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
}
export class FixedWindowRateLimiter {
constructor(
private redis: Redis,
private maxRequests: number,
private windowMs: number
) {}
async checkLimit(key: string): Promise<RateLimitResult> {
const now = Date.now();
const windowKey = this.getWindowKey(key, now);
// Increment counter atomically
const count = await this.redis.incr(windowKey);
// Set TTL on first request in window
if (count === 1) {
await this.redis.pexpire(windowKey, this.windowMs);
}
const allowed = count <= this.maxRequests;
const remaining = Math.max(0, this.maxRequests - count);
const resetAt = new Date(this.getWindowStart(now) + this.windowMs);
return { allowed, remaining, resetAt };
}
private getWindowKey(key: string, timestamp: number): string {
const windowStart = this.getWindowStart(timestamp);
return `ratelimit:${key}:${windowStart}`;
}
private getWindowStart(timestamp: number): number {
return Math.floor(timestamp / this.windowMs) * this.windowMs;
}
}
// Usage
const limiter = new FixedWindowRateLimiter(
redis,
100, // max requests
60000 // per 60 seconds
);
const result = await limiter.checkLimit(`user:${userId}`);
if (!result.allowed) {
throw new Error('Rate limit exceeded');
}
Sliding Window Log
export class SlidingWindowRateLimiter {
constructor(
private redis: Redis,
private maxRequests: number,
private windowMs: number
) {}
async checkLimit(key: string): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - this.windowMs;
const listKey = `ratelimit:${key}`;
// Use Lua script for atomic operations
const script = `
local listKey = KEYS[1]
local now = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local maxRequests = tonumber(ARGV[3])
local windowMs = tonumber(ARGV[4])
-- Remove old entries
redis.call('ZREMRANGEBYSCORE', listKey, 0, windowStart)
-- Count current requests
local count = redis.call('ZCARD', listKey)
if count < maxRequests then
-- Add new request
redis.call('ZADD', listKey, now, now)
redis.call('PEXPIRE', listKey, windowMs)
return {1, maxRequests - count - 1}
else
return {0, 0}
end
`;
const result = await this.redis.eval(
script,
1,
listKey,
now,
windowStart,
this.maxRequests,
this.windowMs
) as [number, number];
const [allowed, remaining] = result;
const resetAt = new Date(now + this.windowMs);
return {
allowed: allowed === 1,
remaining,
resetAt
};
}
}
Token Bucket
export class TokenBucketRateLimiter {
constructor(
private redis: Redis,
private capacity: number, // max tokens
private refillRate: number, // tokens per second
) {}
async checkLimit(key: string, cost: number = 1): Promise<RateLimitResult> {
const bucketKey = `ratelimit:tb:${key}`;
const now = Date.now();
const script = `
local bucketKey = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- Get current bucket state
local bucket = redis.call('HMGET', bucketKey, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
-- Calculate tokens to add based on time passed
local timePassed = (now - lastRefill) / 1000
local tokensToAdd = timePassed * refillRate
tokens = math.min(capacity, tokens + tokensToAdd)
local allowed = 0
local remaining = tokens
if tokens >= cost then
tokens = tokens - cost
allowed = 1
remaining = tokens
end
-- Update bucket state
redis.call('HMSET', bucketKey, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', bucketKey, 3600) -- Expire after 1 hour of inactivity
return {allowed, math.floor(remaining)}
`;
const result = await this.redis.eval(
script,
1,
bucketKey,
this.capacity,
this.refillRate,
cost,
now
) as [number, number];
const [allowed, remaining] = result;
return {
allowed: allowed === 1,
remaining,
resetAt: new Date(now + ((this.capacity - remaining) / this.refillRate) * 1000)
};
}
}
// Usage
const limiter = new TokenBucketRateLimiter(
redis,
100, // 100 tokens capacity
10 // refill 10 tokens per second
);
// Normal request (costs 1 token)
await limiter.checkLimit(`user:${userId}`);
// Expensive operation (costs 10 tokens)
await limiter.checkLimit(`user:${userId}`, 10);
Express Middleware
Integrate rate limiting into your Express app:
import { Request, Response, NextFunction } from 'express';
export function createRateLimitMiddleware(
limiter: FixedWindowRateLimiter | SlidingWindowRateLimiter | TokenBucketRateLimiter,
keyGenerator: (req: Request) => string = (req) => req.ip
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const key = keyGenerator(req);
const result = await limiter.checkLimit(key);
// Set rate limit headers
res.setHeader('X-RateLimit-Limit', limiter.maxRequests || limiter.capacity);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt.getTime());
if (!result.allowed) {
res.setHeader('Retry-After', Math.ceil((result.resetAt.getTime() - Date.now()) / 1000));
return res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retryAfter: result.resetAt.toISOString()
});
}
next();
} catch (error) {
// Fail open: allow request if rate limiter fails
console.error('Rate limiter error:', error);
next();
}
};
}
// Usage
import express from 'express';
const app = express();
// Global rate limit: 100 requests per minute per IP
app.use(createRateLimitMiddleware(
new FixedWindowRateLimiter(redis, 100, 60000)
));
// API-specific rate limit: 1000 requests per hour per API key
app.use('/api', createRateLimitMiddleware(
new SlidingWindowRateLimiter(redis, 1000, 3600000),
(req) => req.headers['x-api-key'] as string
));
// Expensive operation: token bucket with bursts
app.post('/api/search', createRateLimitMiddleware(
new TokenBucketRateLimiter(redis, 50, 5), // 50 capacity, 5/sec refill
(req) => `user:${req.user?.id}`
));
Advanced Patterns
Tiered Rate Limiting
Different limits for different user tiers:
class TieredRateLimiter {
private limiters: Map<string, SlidingWindowRateLimiter> = new Map();
constructor(
private redis: Redis,
private tiers: Record<string, { maxRequests: number; windowMs: number }>
) {
for (const [tier, config] of Object.entries(tiers)) {
this.limiters.set(
tier,
new SlidingWindowRateLimiter(redis, config.maxRequests, config.windowMs)
);
}
}
async checkLimit(tier: string, key: string): Promise<RateLimitResult> {
const limiter = this.limiters.get(tier);
if (!limiter) {
throw new Error(`Unknown tier: ${tier}`);
}
return limiter.checkLimit(key);
}
}
// Usage
const tieredLimiter = new TieredRateLimiter(redis, {
free: { maxRequests: 10, windowMs: 60000 }, // 10/min
basic: { maxRequests: 100, windowMs: 60000 }, // 100/min
premium: { maxRequests: 1000, windowMs: 60000 } // 1000/min
});
app.use(async (req, res, next) => {
const tier = req.user?.subscription || 'free';
const result = await tieredLimiter.checkLimit(tier, `user:${req.user?.id}`);
if (!result.allowed) {
return res.status(429).json({
error: 'Rate limit exceeded',
tier,
upgradeUrl: '/pricing'
});
}
next();
});
Distributed Rate Limiting
For multiple Redis instances:
class DistributedRateLimiter {
constructor(
private redisClients: Redis[],
private maxRequests: number,
private windowMs: number
) {}
async checkLimit(key: string): Promise<RateLimitResult> {
// Check all Redis instances
const results = await Promise.all(
this.redisClients.map(client => {
const limiter = new FixedWindowRateLimiter(client, this.maxRequests, this.windowMs);
return limiter.checkLimit(key);
})
);
// Aggregate results (take minimum remaining)
const minRemaining = Math.min(...results.map(r => r.remaining));
const allowed = results.every(r => r.allowed);
return {
allowed,
remaining: minRemaining,
resetAt: results[0].resetAt
};
}
}
Testing
import { describe, it, expect, beforeEach } from '@jest/globals';
import Redis from 'ioredis-mock';
describe('FixedWindowRateLimiter', () => {
let redis: Redis;
let limiter: FixedWindowRateLimiter;
beforeEach(() => {
redis = new Redis();
limiter = new FixedWindowRateLimiter(redis, 5, 1000); // 5 requests per second
});
it('should allow requests within limit', async () => {
for (let i = 0; i < 5; i++) {
const result = await limiter.checkLimit('test-key');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4 - i);
}
});
it('should block requests over limit', async () => {
// Use up the limit
for (let i = 0; i < 5; i++) {
await limiter.checkLimit('test-key');
}
// Next request should be blocked
const result = await limiter.checkLimit('test-key');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it('should reset after window', async () => {
// Use up the limit
for (let i = 0; i < 5; i++) {
await limiter.checkLimit('test-key');
}
// Wait for window to reset
await new Promise(resolve => setTimeout(resolve, 1100));
// Should allow requests again
const result = await limiter.checkLimit('test-key');
expect(result.allowed).toBe(true);
});
});
Monitoring and Observability
class ObservableRateLimiter {
constructor(
private limiter: FixedWindowRateLimiter,
private metrics: MetricsClient
) {}
async checkLimit(key: string): Promise<RateLimitResult> {
const start = Date.now();
const result = await this.limiter.checkLimit(key);
const duration = Date.now() - start;
// Record metrics
this.metrics.histogram('rate_limiter.check_duration', duration);
this.metrics.increment('rate_limiter.checks_total');
if (!result.allowed) {
this.metrics.increment('rate_limiter.blocked_total', { key });
}
return result;
}
}
Best Practices
1. Fail Open
If Redis is down, allow requests (don't block all traffic):
try {
const result = await limiter.checkLimit(key);
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
} catch (error) {
// Log error but allow request
logger.error('Rate limiter error', error);
}
2. Use Lua Scripts
For atomic operations across multiple Redis commands:
// Good: Atomic
const script = `
local current = redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
return current
`;
// Bad: Race condition
const current = await redis.incr(key);
await redis.expire(key, ttl);
3. Set Appropriate Headers
Help clients handle rate limits:
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt.getTime());
res.setHeader('Retry-After', retryAfterSeconds);
4. Different Limits for Different Endpoints
More generous limits for cheap operations:
app.get('/health', createRateLimitMiddleware(
new FixedWindowRateLimiter(redis, 1000, 60000) // 1000/min
));
app.post('/api/search', createRateLimitMiddleware(
new FixedWindowRateLimiter(redis, 10, 60000) // 10/min
));
Production Checklist
- [ ] Choose appropriate algorithm for your use case
- [ ] Set realistic limits (test with real traffic)
- [ ] Implement proper error handling (fail open)
- [ ] Add monitoring and alerting
- [ ] Set standard rate limit headers
- [ ] Document limits for API consumers
- [ ] Test with realistic load
- [ ] Have Redis failover plan
- [ ] Consider burst allowances
- [ ] Implement tiered limits if needed
Further Reading
Have you implemented rate limiting? What challenges did you face? What algorithm works best for your use case?