Solidis logoSolidis
← 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 Limiter
The 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 Limiter
More 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 Limiter
Allow 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 Integration
Easy-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 algorithm
    Fixed window for simplicity, sliding window for accuracy, token bucket for burst traffic
  • ✓
    Always include rate limit headers
    Help clients understand and respect rate limits
  • ✓
    Fail open on errors
    Don't block all traffic if Redis is down
  • ✓
    Use different limits for different endpoints
    More restrictive for expensive operations