Back to Blog
APIRate LimitingNode.jsSecurity

API Rate Limiting Implementation

Implement effective rate limiting for APIs. From token bucket to sliding window to distributed rate limiting.

B
Bootspring Team
Engineering
January 19, 2022
7 min read

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.

Share this article

Help spread the word about Bootspring