← Back to Tutorials
Intermediate
20 min
Rate Limiting with Redis
Protect your APIs from abuse and share resources fairly with Redis-based rate limiting.
What You'll Learn
- Fixed window rate limiting
- Sliding window rate limiting
- Token bucket algorithm
- Express middleware integration
1
Fixed Window Rate LimiterThe simplest rate-limiting strategy
1import { SolidisFeaturedClient } from '@vcms-io/solidis/featured';
2
3export class FixedWindowRateLimiter {
4 private client: SolidisFeaturedClient;
5 private prefix: string;
6
7 constructor(options: { host?: string; port?: number; prefix?: string } = {}) {
8 this.client = new SolidisFeaturedClient({
9 host: options.host || '127.0.0.1',
10 port: options.port || 6379,
11 });
12 this.prefix = options.prefix || 'rate:';
13 }
14
15 async connect(): Promise<void> {
16 await this.client.connect();
17 }
18
19 /**
20 * Check if request is allowed
21 * @param key - Identifier (e.g., user ID, IP address)
22 * @param limit - Maximum requests allowed
23 * @param windowSeconds - Time window in seconds
24 */
25 async checkLimit(
26 key: string,
27 limit: number,
28 windowSeconds: number
29 ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
30 const now = Date.now();
31 const windowStart = Math.floor(now / (windowSeconds * 1000));
32 const cacheKey = `${this.prefix}${key}:${windowStart}`;
33
34 // Increment counter
35 const count = await this.client.incr(cacheKey);
36
37 // Set expiry on first request
38 if (count === 1) {
39 await this.client.expire(cacheKey, windowSeconds);
40 }
41
42 const allowed = count <= limit;
43 const remaining = Math.max(0, limit - count);
44 const resetAt = new Date((windowStart + 1) * windowSeconds * 1000);
45
46 return { allowed, remaining, resetAt };
47 }
48}2
Sliding Window Rate LimiterMore accurate rate limiting with smooth distribution, decided atomically by a Lua script
1import { randomUUID } from 'node:crypto';
2import { SolidisFeaturedClient } from '@vcms-io/solidis/featured';
3
4// Trims the window, counts it and records the request in one atomic step,
5// so concurrent requests cannot all read the same count
6const SLIDING_WINDOW_SCRIPT = `
7redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
8local count = redis.call('ZCARD', KEYS[1])
9if count < tonumber(ARGV[3]) then
10 redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
11 redis.call('PEXPIRE', KEYS[1], ARGV[2])
12 return {1, count + 1, 0}
13end
14local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
15return {0, count, oldest[2] + ARGV[2] - ARGV[1]}
16`;
17
18export class SlidingWindowRateLimiter {
19 private client: SolidisFeaturedClient;
20 private prefix: string;
21
22 constructor(options: { host?: string; port?: number; prefix?: string } = {}) {
23 this.client = new SolidisFeaturedClient({
24 host: options.host || '127.0.0.1',
25 port: options.port || 6379,
26 });
27 this.prefix = options.prefix || 'rate:sliding:';
28 }
29
30 async connect(): Promise<void> {
31 await this.client.connect();
32 }
33
34 /**
35 * Check if request is allowed using sliding window
36 */
37 async checkLimit(
38 key: string,
39 limit: number,
40 windowSeconds: number
41 ): Promise<{ allowed: boolean; remaining: number; retryAfter: number }> {
42 const now = Date.now();
43 const reply = await this.client.eval(
44 SLIDING_WINDOW_SCRIPT,
45 [`${this.prefix}${key}`],
46 [`${now}`, `${windowSeconds * 1000}`, `${limit}`, `${now}:${randomUUID()}`]
47 );
48 const [allowed, count, retryAfterMs] = Array.isArray(reply)
49 ? reply.map(Number)
50 : [];
51
52 return {
53 allowed: allowed === 1,
54 remaining: Math.max(0, limit - count),
55 retryAfter: Math.ceil(retryAfterMs / 1000),
56 };
57 }
58}3
Token Bucket Rate LimiterAllow burst traffic while maintaining average rate, updated atomically by a Lua script
1import { SolidisFeaturedClient } from '@vcms-io/solidis/featured';
2
3// Refills and takes a token in one atomic step,
4// so concurrent requests cannot spend the same token
5const TOKEN_BUCKET_SCRIPT = `
6local capacity = tonumber(ARGV[1])
7local refillRate = tonumber(ARGV[2])
8local now = tonumber(ARGV[3])
9local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
10local tokens = tonumber(bucket[1]) or capacity
11local lastRefill = tonumber(bucket[2]) or now
12tokens = math.min(capacity, tokens + (now - lastRefill) / 1000 * refillRate)
13local allowed = 0
14if tokens >= 1 then
15 tokens = tokens - 1
16 allowed = 1
17end
18redis.call('HSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
19redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate) + 60)
20return {allowed, tostring(tokens)}
21`;
22
23export class TokenBucketRateLimiter {
24 private client: SolidisFeaturedClient;
25 private prefix: string;
26
27 constructor(options: { host?: string; port?: number; prefix?: string } = {}) {
28 this.client = new SolidisFeaturedClient({
29 host: options.host || '127.0.0.1',
30 port: options.port || 6379,
31 });
32 this.prefix = options.prefix || 'rate:bucket:';
33 }
34
35 async connect(): Promise<void> {
36 await this.client.connect();
37 }
38
39 /**
40 * Check if request is allowed using token bucket
41 * @param capacity - Maximum tokens in bucket
42 * @param refillRate - Tokens added per second
43 */
44 async checkLimit(
45 key: string,
46 capacity: number,
47 refillRate: number
48 ): Promise<{ allowed: boolean; tokens: number; retryAfter: number }> {
49 const reply = await this.client.eval(
50 TOKEN_BUCKET_SCRIPT,
51 [`${this.prefix}${key}`],
52 [`${capacity}`, `${refillRate}`, `${Date.now()}`]
53 );
54 const [allowed, tokens] = Array.isArray(reply)
55 ? reply.map((value) => Number(String(value)))
56 : [];
57
58 return {
59 allowed: allowed === 1,
60 tokens: Math.floor(tokens),
61 retryAfter: allowed === 1 ? 0 : Math.ceil((1 - tokens) / refillRate),
62 };
63 }
64}4
Express Middleware IntegrationEasy-to-use middleware for your Express application
1import { Request, Response, NextFunction } from 'express';
2
3declare global {
4 namespace Express {
5 interface Request {
6 user?: { id: string; role: string };
7 }
8 }
9}
10import { SlidingWindowRateLimiter } from './sliding-window-limiter';
11
12export interface RateLimitOptions {
13 windowSeconds?: number;
14 limit?: number;
15 keyGenerator?: (req: Request) => string;
16 skip?: (req: Request) => boolean;
17 handler?: (req: Request, res: Response) => void;
18}
19
20export function createRateLimiter(
21 limiter: SlidingWindowRateLimiter,
22 options: RateLimitOptions = {}
23) {
24 const {
25 windowSeconds = 60,
26 limit = 100,
27 keyGenerator = (req) => req.ip || 'unknown',
28 skip = () => false,
29 handler = (req, res) => {
30 res.status(429).json({
31 error: 'Too many requests',
32 message: 'Please try again later',
33 });
34 },
35 } = options;
36
37 return async (req: Request, res: Response, next: NextFunction) => {
38 // Skip rate limiting if condition is met
39 if (skip(req)) {
40 return next();
41 }
42
43 const key = keyGenerator(req);
44
45 try {
46 const result = await limiter.checkLimit(key, limit, windowSeconds);
47
48 // Set rate limit headers
49 res.setHeader('X-RateLimit-Limit', limit.toString());
50 res.setHeader('X-RateLimit-Remaining', result.remaining.toString());
51
52 if (!result.allowed) {
53 res.setHeader('Retry-After', result.retryAfter.toString());
54 return handler(req, res);
55 }
56
57 next();
58 } catch (error) {
59 console.error('Rate limiter error:', error);
60 // Fail open - allow request on error
61 next();
62 }
63 };
64}
65
66// Usage examples
67export function createUserRateLimiter(limiter: SlidingWindowRateLimiter) {
68 return createRateLimiter(limiter, {
69 windowSeconds: 60,
70 limit: 100,
71 keyGenerator: (req) => `user:${req.user?.id || req.ip}`,
72 skip: (req) => req.user?.role === 'admin',
73 });
74}
75
76export function createAPIRateLimiter(limiter: SlidingWindowRateLimiter) {
77 return createRateLimiter(limiter, {
78 windowSeconds: 3600, // 1 hour
79 limit: 1000,
80 keyGenerator: (req) => {
81 const apiKey = req.headers['x-api-key'] as string;
82 return `api:${apiKey || req.ip}`;
83 },
84 });
85}
86
87export function createLoginRateLimiter(limiter: SlidingWindowRateLimiter) {
88 return createRateLimiter(limiter, {
89 windowSeconds: 900, // 15 minutes
90 limit: 5,
91 keyGenerator: (req) => `login:${req.ip}`,
92 handler: (req, res) => {
93 res.status(429).json({
94 error: 'Too many login attempts',
95 message: 'Please try again in 15 minutes',
96 });
97 },
98 });
99}Complete Usage Example
1import express from 'express';
2import { SlidingWindowRateLimiter } from './sliding-window-limiter';
3import {
4 createUserRateLimiter,
5 createAPIRateLimiter,
6 createLoginRateLimiter,
7} from './rate-limit-middleware';
8
9const app = express();
10const limiter = new SlidingWindowRateLimiter({
11 host: '127.0.0.1',
12 port: 6379,
13});
14
15app.use(express.json());
16
17// Global rate limiter
18app.use(createUserRateLimiter(limiter));
19
20// Stricter rate limit for login
21app.post('/api/login', createLoginRateLimiter(limiter), async (req, res) => {
22 // Login logic
23 res.json({ message: 'Login successful' });
24});
25
26// API key based rate limiting
27app.use('/api/v1', createAPIRateLimiter(limiter));
28
29app.get('/api/v1/data', (req, res) => {
30 res.json({ data: 'sensitive data' });
31});
32
33// Start server
34async function start() {
35 await limiter.connect();
36 app.listen(3000, () => {
37 console.log('Server running on http://localhost:3000');
38 });
39}
40
41start().catch(console.error);Best Practices
- ✓Choose the right algorithmFixed window for simplicity, sliding window for accuracy, token bucket for burst traffic
- ✓Always include rate limit headersHelp clients understand and respect rate limits
- ✓Fail open on errorsDon't block all traffic if Redis is down
- ✓Use different limits for different endpointsMore restrictive for expensive operations
