Back to Blog
Rate LimitingAPISecurityPerformance

Rate Limiting and Throttling: Protecting Your APIs

Implement rate limiting that protects your services without frustrating users. From token buckets to sliding windows to distributed limiting.

B
Bootspring Team
Engineering
March 28, 2025
7 min read

Rate limiting protects your APIs from abuse, ensures fair resource distribution, and maintains service stability. Here's how to implement effective rate limiting strategies.

Why Rate Limit?#

Without rate limiting: - Single client can overwhelm your service - No protection against DoS attacks - Unfair resource distribution - Unpredictable costs With rate limiting: - Guaranteed service availability - Protection from abuse - Fair usage across clients - Predictable scaling

Rate Limiting Algorithms#

Fixed Window#

1class FixedWindowLimiter { 2 private counts = new Map<string, { count: number; windowStart: number }>(); 3 4 constructor( 5 private maxRequests: number, 6 private windowMs: number, 7 ) {} 8 9 isAllowed(key: string): boolean { 10 const now = Date.now(); 11 const windowStart = Math.floor(now / this.windowMs) * this.windowMs; 12 13 const record = this.counts.get(key); 14 15 if (!record || record.windowStart !== windowStart) { 16 this.counts.set(key, { count: 1, windowStart }); 17 return true; 18 } 19 20 if (record.count >= this.maxRequests) { 21 return false; 22 } 23 24 record.count++; 25 return true; 26 } 27} 28 29// 100 requests per minute 30const limiter = new FixedWindowLimiter(100, 60000);

Sliding Window Log#

1class SlidingWindowLogLimiter { 2 private logs = new Map<string, number[]>(); 3 4 constructor( 5 private maxRequests: number, 6 private windowMs: number, 7 ) {} 8 9 isAllowed(key: string): boolean { 10 const now = Date.now(); 11 const windowStart = now - this.windowMs; 12 13 let timestamps = this.logs.get(key) || []; 14 15 // Remove old entries 16 timestamps = timestamps.filter(t => t > windowStart); 17 18 if (timestamps.length >= this.maxRequests) { 19 this.logs.set(key, timestamps); 20 return false; 21 } 22 23 timestamps.push(now); 24 this.logs.set(key, timestamps); 25 return true; 26 } 27}

Sliding Window Counter#

1class SlidingWindowCounterLimiter { 2 private windows = new Map<string, { current: number; previous: number; currentStart: number }>(); 3 4 constructor( 5 private maxRequests: number, 6 private windowMs: number, 7 ) {} 8 9 isAllowed(key: string): boolean { 10 const now = Date.now(); 11 const currentWindow = Math.floor(now / this.windowMs); 12 const currentStart = currentWindow * this.windowMs; 13 14 let record = this.windows.get(key); 15 16 if (!record || record.currentStart < currentStart - this.windowMs) { 17 record = { current: 0, previous: 0, currentStart }; 18 } else if (record.currentStart < currentStart) { 19 record = { current: 0, previous: record.current, currentStart }; 20 } 21 22 // Calculate weighted count 23 const elapsedRatio = (now - currentStart) / this.windowMs; 24 const weightedCount = record.current + record.previous * (1 - elapsedRatio); 25 26 if (weightedCount >= this.maxRequests) { 27 this.windows.set(key, record); 28 return false; 29 } 30 31 record.current++; 32 this.windows.set(key, record); 33 return true; 34 } 35}

Token Bucket#

1class TokenBucketLimiter { 2 private buckets = new Map<string, { tokens: number; lastRefill: number }>(); 3 4 constructor( 5 private bucketSize: number, // Max tokens 6 private refillRate: number, // Tokens per second 7 ) {} 8 9 isAllowed(key: string, tokens: number = 1): boolean { 10 const now = Date.now(); 11 let bucket = this.buckets.get(key); 12 13 if (!bucket) { 14 bucket = { tokens: this.bucketSize, lastRefill: now }; 15 } 16 17 // Refill tokens 18 const elapsed = (now - bucket.lastRefill) / 1000; 19 bucket.tokens = Math.min( 20 this.bucketSize, 21 bucket.tokens + elapsed * this.refillRate, 22 ); 23 bucket.lastRefill = now; 24 25 if (bucket.tokens < tokens) { 26 this.buckets.set(key, bucket); 27 return false; 28 } 29 30 bucket.tokens -= tokens; 31 this.buckets.set(key, bucket); 32 return true; 33 } 34} 35 36// 100 tokens max, refill 10 per second 37const limiter = new TokenBucketLimiter(100, 10);

Leaky Bucket#

1class LeakyBucketLimiter { 2 private queues = new Map<string, { queue: Array<() => void>; processing: boolean }>(); 3 4 constructor( 5 private bucketSize: number, 6 private leakRateMs: number, // Time between leaks 7 ) {} 8 9 async process<T>(key: string, task: () => Promise<T>): Promise<T> { 10 return new Promise((resolve, reject) => { 11 let bucket = this.queues.get(key); 12 13 if (!bucket) { 14 bucket = { queue: [], processing: false }; 15 this.queues.set(key, bucket); 16 } 17 18 if (bucket.queue.length >= this.bucketSize) { 19 reject(new Error('Rate limit exceeded')); 20 return; 21 } 22 23 bucket.queue.push(async () => { 24 try { 25 resolve(await task()); 26 } catch (e) { 27 reject(e); 28 } 29 }); 30 31 this.startProcessing(key); 32 }); 33 } 34 35 private async startProcessing(key: string): Promise<void> { 36 const bucket = this.queues.get(key)!; 37 38 if (bucket.processing) return; 39 bucket.processing = true; 40 41 while (bucket.queue.length > 0) { 42 const task = bucket.queue.shift()!; 43 await task(); 44 await new Promise(r => setTimeout(r, this.leakRateMs)); 45 } 46 47 bucket.processing = false; 48 } 49}

Distributed Rate Limiting#

Redis Implementation#

1import Redis from 'ioredis'; 2 3class RedisRateLimiter { 4 constructor( 5 private redis: Redis, 6 private maxRequests: number, 7 private windowMs: number, 8 ) {} 9 10 async isAllowed(key: string): Promise<{ allowed: boolean; remaining: number; resetAt: number }> { 11 const now = Date.now(); 12 const windowKey = `ratelimit:${key}:${Math.floor(now / this.windowMs)}`; 13 14 const multi = this.redis.multi(); 15 multi.incr(windowKey); 16 multi.pttl(windowKey); 17 18 const [[, count], [, ttl]] = await multi.exec() as [[null, number], [null, number]]; 19 20 if (ttl === -1) { 21 await this.redis.pexpire(windowKey, this.windowMs); 22 } 23 24 const resetAt = Math.ceil(now / this.windowMs) * this.windowMs; 25 const remaining = Math.max(0, this.maxRequests - count); 26 27 return { 28 allowed: count <= this.maxRequests, 29 remaining, 30 resetAt, 31 }; 32 } 33}

Lua Script for Atomicity#

1class AtomicRedisLimiter { 2 private script = ` 3 local key = KEYS[1] 4 local limit = tonumber(ARGV[1]) 5 local window = tonumber(ARGV[2]) 6 7 local current = redis.call('INCR', key) 8 9 if current == 1 then 10 redis.call('PEXPIRE', key, window) 11 end 12 13 if current > limit then 14 return {0, limit - current, redis.call('PTTL', key)} 15 end 16 17 return {1, limit - current, redis.call('PTTL', key)} 18 `; 19 20 async isAllowed(key: string): Promise<RateLimitResult> { 21 const [allowed, remaining, ttl] = await this.redis.eval( 22 this.script, 23 1, 24 `ratelimit:${key}`, 25 this.maxRequests, 26 this.windowMs, 27 ) as [number, number, number]; 28 29 return { 30 allowed: allowed === 1, 31 remaining, 32 resetAt: Date.now() + ttl, 33 }; 34 } 35}

Express Middleware#

1import rateLimit from 'express-rate-limit'; 2import RedisStore from 'rate-limit-redis'; 3 4// Basic rate limiting 5const limiter = rateLimit({ 6 windowMs: 15 * 60 * 1000, // 15 minutes 7 max: 100, 8 message: { error: 'Too many requests, please try again later' }, 9 standardHeaders: true, 10 legacyHeaders: false, 11}); 12 13app.use('/api', limiter); 14 15// Redis store for distributed systems 16const distributedLimiter = rateLimit({ 17 windowMs: 15 * 60 * 1000, 18 max: 100, 19 store: new RedisStore({ 20 sendCommand: (...args: string[]) => redis.call(...args), 21 }), 22}); 23 24// Different limits per route 25const authLimiter = rateLimit({ 26 windowMs: 60 * 60 * 1000, // 1 hour 27 max: 5, 28 message: { error: 'Too many login attempts' }, 29}); 30 31app.use('/api/auth/login', authLimiter);

Response Headers#

1function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) { 2 const key = req.ip; 3 const result = limiter.check(key); 4 5 // Standard headers 6 res.setHeader('RateLimit-Limit', result.limit); 7 res.setHeader('RateLimit-Remaining', result.remaining); 8 res.setHeader('RateLimit-Reset', Math.ceil(result.resetAt / 1000)); 9 10 // Retry-After for 429 responses 11 if (!result.allowed) { 12 const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000); 13 res.setHeader('Retry-After', retryAfter); 14 return res.status(429).json({ 15 error: 'Too many requests', 16 retryAfter, 17 }); 18 } 19 20 next(); 21}

Advanced Patterns#

Tiered Rate Limits#

1const tierLimits = { 2 free: { requests: 100, window: 3600000 }, 3 pro: { requests: 1000, window: 3600000 }, 4 enterprise: { requests: 10000, window: 3600000 }, 5}; 6 7async function checkRateLimit(userId: string): Promise<boolean> { 8 const user = await getUser(userId); 9 const tier = tierLimits[user.plan]; 10 11 return limiter.isAllowed(userId, tier.requests, tier.window); 12}

Endpoint-Specific Limits#

1const endpointLimits = { 2 'POST /api/upload': { requests: 10, window: 60000 }, 3 'GET /api/search': { requests: 30, window: 60000 }, 4 'POST /api/messages': { requests: 60, window: 60000 }, 5 default: { requests: 100, window: 60000 }, 6}; 7 8function getLimit(method: string, path: string) { 9 const key = `${method} ${path}`; 10 return endpointLimits[key] || endpointLimits.default; 11}

Cost-Based Limiting#

1// Different operations have different costs 2const operationCosts = { 3 'GET /api/users': 1, 4 'POST /api/users': 5, 5 'GET /api/reports': 10, 6 'POST /api/export': 50, 7}; 8 9class CostBasedLimiter { 10 private tokenBucket: TokenBucketLimiter; 11 12 constructor(tokensPerHour: number) { 13 this.tokenBucket = new TokenBucketLimiter(tokensPerHour, tokensPerHour / 3600); 14 } 15 16 isAllowed(key: string, operation: string): boolean { 17 const cost = operationCosts[operation] || 1; 18 return this.tokenBucket.isAllowed(key, cost); 19 } 20}

Graceful Degradation#

1class GracefulLimiter { 2 async handleRequest(req: Request, res: Response): Promise<void> { 3 const result = await this.limiter.check(req.ip); 4 5 if (result.allowed) { 6 return this.processFullRequest(req, res); 7 } 8 9 // Near limit: return cached/simplified response 10 if (result.remaining < 10) { 11 return this.processDegradedRequest(req, res); 12 } 13 14 // Over limit: reject 15 res.status(429).json({ error: 'Rate limit exceeded' }); 16 } 17 18 private async processDegradedRequest(req: Request, res: Response): Promise<void> { 19 // Return cached data 20 const cached = await this.cache.get(req.url); 21 if (cached) { 22 res.setHeader('X-Degraded-Response', 'true'); 23 return res.json(cached); 24 } 25 26 res.status(429).json({ error: 'Rate limit exceeded' }); 27 } 28}

Conclusion#

Rate limiting is essential for API reliability and security. Choose the right algorithm for your use case—token bucket for bursty traffic, sliding window for smooth limiting. Always return proper headers so clients can adapt.

Remember: good rate limiting protects your service while giving users clear feedback and reasonable limits.

Share this article

Help spread the word about Bootspring