Rate limiting protects APIs from abuse and ensures fair usage. Here's how to implement it effectively.
Rate Limiting Algorithms#
1// 1. Fixed Window Counter
2class FixedWindowCounter {
3 private counts: Map<string, { count: number; windowStart: number }> = new Map();
4 private windowMs: number;
5 private maxRequests: number;
6
7 constructor(windowMs: number, maxRequests: number) {
8 this.windowMs = windowMs;
9 this.maxRequests = maxRequests;
10 }
11
12 isAllowed(key: string): boolean {
13 const now = Date.now();
14 const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
15
16 const entry = this.counts.get(key);
17
18 if (!entry || entry.windowStart !== windowStart) {
19 this.counts.set(key, { count: 1, windowStart });
20 return true;
21 }
22
23 if (entry.count >= this.maxRequests) {
24 return false;
25 }
26
27 entry.count++;
28 return true;
29 }
30}
31
32// 2. Sliding Window Log
33class SlidingWindowLog {
34 private logs: Map<string, number[]> = new Map();
35 private windowMs: number;
36 private maxRequests: number;
37
38 constructor(windowMs: number, maxRequests: number) {
39 this.windowMs = windowMs;
40 this.maxRequests = maxRequests;
41 }
42
43 isAllowed(key: string): boolean {
44 const now = Date.now();
45 const windowStart = now - this.windowMs;
46
47 let timestamps = this.logs.get(key) || [];
48
49 // Remove old timestamps
50 timestamps = timestamps.filter((ts) => ts > windowStart);
51
52 if (timestamps.length >= this.maxRequests) {
53 this.logs.set(key, timestamps);
54 return false;
55 }
56
57 timestamps.push(now);
58 this.logs.set(key, timestamps);
59 return true;
60 }
61}
62
63// 3. Token Bucket
64class TokenBucket {
65 private buckets: Map<string, { tokens: number; lastRefill: number }> = new Map();
66 private capacity: number;
67 private refillRate: number; // tokens per second
68
69 constructor(capacity: number, refillRate: number) {
70 this.capacity = capacity;
71 this.refillRate = refillRate;
72 }
73
74 isAllowed(key: string, tokensRequired: number = 1): boolean {
75 const now = Date.now();
76 let bucket = this.buckets.get(key);
77
78 if (!bucket) {
79 bucket = { tokens: this.capacity, lastRefill: now };
80 this.buckets.set(key, bucket);
81 }
82
83 // Refill tokens
84 const elapsed = (now - bucket.lastRefill) / 1000;
85 bucket.tokens = Math.min(
86 this.capacity,
87 bucket.tokens + elapsed * this.refillRate
88 );
89 bucket.lastRefill = now;
90
91 if (bucket.tokens >= tokensRequired) {
92 bucket.tokens -= tokensRequired;
93 return true;
94 }
95
96 return false;
97 }
98}Express Middleware#
1import { Request, Response, NextFunction } from 'express';
2import Redis from 'ioredis';
3
4const redis = new Redis();
5
6interface RateLimitOptions {
7 windowMs: number;
8 maxRequests: number;
9 keyGenerator?: (req: Request) => string;
10 handler?: (req: Request, res: Response) => void;
11 skip?: (req: Request) => boolean;
12}
13
14function rateLimiter(options: RateLimitOptions) {
15 const {
16 windowMs,
17 maxRequests,
18 keyGenerator = (req) => req.ip || 'unknown',
19 handler = (req, res) => {
20 res.status(429).json({
21 error: 'Too Many Requests',
22 retryAfter: Math.ceil(windowMs / 1000),
23 });
24 },
25 skip = () => false,
26 } = options;
27
28 return async (req: Request, res: Response, next: NextFunction) => {
29 if (skip(req)) {
30 return next();
31 }
32
33 const key = `ratelimit:${keyGenerator(req)}`;
34
35 try {
36 // Use Redis for distributed rate limiting
37 const current = await redis.incr(key);
38
39 if (current === 1) {
40 await redis.pexpire(key, windowMs);
41 }
42
43 const ttl = await redis.pttl(key);
44
45 // Set rate limit headers
46 res.set({
47 'X-RateLimit-Limit': String(maxRequests),
48 'X-RateLimit-Remaining': String(Math.max(0, maxRequests - current)),
49 'X-RateLimit-Reset': String(Math.ceil((Date.now() + ttl) / 1000)),
50 });
51
52 if (current > maxRequests) {
53 res.set('Retry-After', String(Math.ceil(ttl / 1000)));
54 return handler(req, res);
55 }
56
57 next();
58 } catch (error) {
59 // Fail open - don't block requests if Redis is down
60 console.error('Rate limiter error:', error);
61 next();
62 }
63 };
64}
65
66// Usage
67app.use(rateLimiter({
68 windowMs: 60 * 1000, // 1 minute
69 maxRequests: 100,
70}));
71
72// Different limits for different routes
73app.use('/api/auth', rateLimiter({
74 windowMs: 15 * 60 * 1000, // 15 minutes
75 maxRequests: 5,
76 keyGenerator: (req) => `auth:${req.ip}`,
77}));
78
79app.use('/api/upload', rateLimiter({
80 windowMs: 60 * 60 * 1000, // 1 hour
81 maxRequests: 10,
82 keyGenerator: (req) => `upload:${req.user?.id || req.ip}`,
83}));Sliding Window Counter (Redis)#
1// More accurate than fixed window, uses less memory than log
2class SlidingWindowCounter {
3 private redis: Redis;
4 private windowMs: number;
5 private maxRequests: number;
6
7 constructor(redis: Redis, windowMs: number, maxRequests: number) {
8 this.redis = redis;
9 this.windowMs = windowMs;
10 this.maxRequests = maxRequests;
11 }
12
13 async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
14 const now = Date.now();
15 const currentWindow = Math.floor(now / this.windowMs);
16 const previousWindow = currentWindow - 1;
17
18 const currentKey = `${key}:${currentWindow}`;
19 const previousKey = `${key}:${previousWindow}`;
20
21 // Get counts from both windows
22 const [currentCount, previousCount] = await this.redis.mget(currentKey, previousKey);
23
24 const current = parseInt(currentCount || '0', 10);
25 const previous = parseInt(previousCount || '0', 10);
26
27 // Calculate weighted count
28 const windowProgress = (now % this.windowMs) / this.windowMs;
29 const weightedCount = Math.floor(previous * (1 - windowProgress)) + current;
30
31 if (weightedCount >= this.maxRequests) {
32 return {
33 allowed: false,
34 remaining: 0,
35 resetAt: (currentWindow + 1) * this.windowMs,
36 };
37 }
38
39 // Increment current window
40 await this.redis
41 .multi()
42 .incr(currentKey)
43 .pexpire(currentKey, this.windowMs * 2)
44 .exec();
45
46 return {
47 allowed: true,
48 remaining: this.maxRequests - weightedCount - 1,
49 resetAt: (currentWindow + 1) * this.windowMs,
50 };
51 }
52}Tiered Rate Limiting#
1interface RateLimitTier {
2 requests: number;
3 windowMs: number;
4}
5
6interface UserTiers {
7 free: RateLimitTier;
8 basic: RateLimitTier;
9 premium: RateLimitTier;
10 enterprise: RateLimitTier;
11}
12
13const tiers: UserTiers = {
14 free: { requests: 100, windowMs: 60 * 60 * 1000 }, // 100/hour
15 basic: { requests: 1000, windowMs: 60 * 60 * 1000 }, // 1000/hour
16 premium: { requests: 10000, windowMs: 60 * 60 * 1000 }, // 10000/hour
17 enterprise: { requests: 100000, windowMs: 60 * 60 * 1000 }, // 100000/hour
18};
19
20function tieredRateLimiter() {
21 return async (req: Request, res: Response, next: NextFunction) => {
22 const user = req.user;
23 const tier = user?.subscriptionTier || 'free';
24 const limits = tiers[tier as keyof UserTiers];
25
26 const key = `ratelimit:${user?.id || req.ip}`;
27 const current = await redis.incr(key);
28
29 if (current === 1) {
30 await redis.pexpire(key, limits.windowMs);
31 }
32
33 res.set({
34 'X-RateLimit-Limit': String(limits.requests),
35 'X-RateLimit-Remaining': String(Math.max(0, limits.requests - current)),
36 'X-RateLimit-Tier': tier,
37 });
38
39 if (current > limits.requests) {
40 return res.status(429).json({
41 error: 'Rate limit exceeded',
42 tier,
43 upgradeUrl: '/pricing',
44 });
45 }
46
47 next();
48 };
49}Cost-Based Rate Limiting#
1// Different operations have different costs
2const operationCosts: Record<string, number> = {
3 'GET /api/users': 1,
4 'POST /api/users': 5,
5 'DELETE /api/users': 10,
6 'POST /api/analyze': 100,
7 'POST /api/export': 500,
8};
9
10function costBasedRateLimiter(options: {
11 bucketCapacity: number;
12 refillRate: number;
13}) {
14 const tokenBucket = new TokenBucket(
15 options.bucketCapacity,
16 options.refillRate
17 );
18
19 return async (req: Request, res: Response, next: NextFunction) => {
20 const operation = `${req.method} ${req.path}`;
21 const cost = operationCosts[operation] || 1;
22
23 const key = req.user?.id || req.ip || 'unknown';
24 const bucket = await tokenBucket.getState(key);
25
26 res.set({
27 'X-RateLimit-Limit': String(options.bucketCapacity),
28 'X-RateLimit-Remaining': String(Math.floor(bucket.tokens)),
29 'X-RateLimit-Cost': String(cost),
30 });
31
32 if (!tokenBucket.isAllowed(key, cost)) {
33 return res.status(429).json({
34 error: 'Insufficient tokens',
35 cost,
36 available: bucket.tokens,
37 refillRate: options.refillRate,
38 });
39 }
40
41 next();
42 };
43}Client-Side Handling#
1// API client with retry and backoff
2class RateLimitedClient {
3 private baseUrl: string;
4 private maxRetries: number;
5
6 constructor(baseUrl: string, maxRetries = 3) {
7 this.baseUrl = baseUrl;
8 this.maxRetries = maxRetries;
9 }
10
11 async request<T>(
12 path: string,
13 options: RequestInit = {}
14 ): Promise<T> {
15 let lastError: Error | null = null;
16
17 for (let attempt = 0; attempt < this.maxRetries; attempt++) {
18 try {
19 const response = await fetch(`${this.baseUrl}${path}`, options);
20
21 // Log rate limit info
22 const remaining = response.headers.get('X-RateLimit-Remaining');
23 const limit = response.headers.get('X-RateLimit-Limit');
24
25 if (remaining && parseInt(remaining) < 10) {
26 console.warn(`Rate limit warning: ${remaining}/${limit} remaining`);
27 }
28
29 if (response.status === 429) {
30 const retryAfter = response.headers.get('Retry-After');
31 const waitMs = retryAfter
32 ? parseInt(retryAfter) * 1000
33 : Math.pow(2, attempt) * 1000;
34
35 console.log(`Rate limited. Retrying in ${waitMs}ms...`);
36 await this.sleep(waitMs);
37 continue;
38 }
39
40 if (!response.ok) {
41 throw new Error(`HTTP ${response.status}`);
42 }
43
44 return response.json();
45 } catch (error) {
46 lastError = error as Error;
47 }
48 }
49
50 throw lastError || new Error('Request failed');
51 }
52
53 private sleep(ms: number): Promise<void> {
54 return new Promise((resolve) => setTimeout(resolve, ms));
55 }
56}Best Practices#
Implementation:
✓ Use Redis for distributed systems
✓ Include rate limit headers
✓ Provide clear error messages
✓ Log rate limit events
Strategy:
✓ Different limits for different endpoints
✓ Higher limits for authenticated users
✓ Cost-based for expensive operations
✓ Fail open if limiter fails
UX:
✓ Return remaining quota in headers
✓ Include retry-after header
✓ Provide upgrade path
✓ Document limits clearly
Conclusion#
Rate limiting protects APIs and ensures fair usage. Choose the right algorithm (token bucket for burst tolerance, sliding window for accuracy), use Redis for distributed deployments, and provide clear feedback to clients. Different endpoints and user tiers should have appropriate limits.