API Rate Limiting: Algorithms and Implementation
A deep dive into token bucket, sliding window and leaky bucket rate limiting, with distributed implementations in Redis and practical middleware patterns.

Why Rate Limiting Is Non-Negotiable
Every public API needs rate limiting. Without it, a single misbehaving client—whether malicious or buggy—can consume all available server resources, degrading the experience for every other user. Rate limiting protects your infrastructure, ensures fair access, and provides predictable behavior under load.
The challenge is choosing the right algorithm for your use case. Different algorithms have different trade-offs in burst handling, memory usage, and fairness.
Token Bucket Algorithm
The token bucket is the most common rate limiting algorithm. Tokens are added to a bucket at a fixed rate. Each request consumes a token. If the bucket is empty, the request is rejected. The bucket has a maximum capacity, allowing controlled bursts.
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private readonly capacity: number,
private readonly refillRate: number // tokens per second
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
tryConsume(tokens: number = 1): boolean {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
const newTokens = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + newTokens);
this.lastRefill = now;
}
getState(): { tokens: number; capacity: number } {
this.refill();
return {
tokens: Math.floor(this.tokens),
capacity: this.capacity,
};
}
}
// Usage
const bucket = new TokenBucket(100, 10); // 100 capacity, 10 tokens/sec
if (bucket.tryConsume()) {
// Process request
} else {
// Return 429 Too Many Requests
}The token bucket allows bursts up to the bucket capacity while maintaining a steady long-term rate. This is ideal for APIs where users naturally make requests in bursts—loading a dashboard that fires ten API calls simultaneously.
Sliding Window Counter
The sliding window counter provides smoother rate limiting without the burst allowance of the token bucket. It tracks request counts in small time windows and interpolates between them.
// ❌ Fixed window — allows 2x burst at window boundaries
// At 11:59:59 user sends 100 requests (resets at 12:00:00)
// At 12:00:01 user sends another 100 requests
// Result: 200 requests in 2 seconds despite 100/minute limit
// ✅ Sliding window — smooth rate limiting across boundaries
class SlidingWindowCounter {
private windows: Map<string, number> = new Map();
private readonly windowSize: number; // in milliseconds
constructor(
private readonly maxRequests: number,
private readonly windowMs: number
) {
this.windowSize = windowMs;
}
tryConsume(clientId: string): { allowed: boolean; remaining: number; resetMs: number } {
const now = Date.now();
const currentWindow = Math.floor(now / this.windowSize);
const previousWindow = currentWindow - 1;
const currentKey = `${clientId}:${currentWindow}`;
const previousKey = `${clientId}:${previousWindow}`;
const currentCount = this.windows.get(currentKey) || 0;
const previousCount = this.windows.get(previousKey) || 0;
// Weight the previous window by how much of it overlaps
const elapsedInWindow = (now % this.windowSize) / this.windowSize;
const weightedCount =
previousCount * (1 - elapsedInWindow) + currentCount;
if (weightedCount >= this.maxRequests) {
const resetMs = this.windowSize - (now % this.windowSize);
return {
allowed: false,
remaining: 0,
resetMs,
};
}
this.windows.set(currentKey, currentCount + 1);
this.cleanup(currentWindow);
return {
allowed: true,
remaining: Math.floor(this.maxRequests - weightedCount - 1),
resetMs: this.windowSize - (now % this.windowSize),
};
}
private cleanup(currentWindow: number): void {
for (const key of this.windows.keys()) {
const windowNum = parseInt(key.split(":")[1], 10);
if (windowNum < currentWindow - 1) {
this.windows.delete(key);
}
}
}
}Distributed Rate Limiting with Redis
In-memory rate limiters fail in distributed systems because each server maintains its own count. Redis provides atomic operations for shared state across all instances.
import { Redis } from "ioredis";
class RedisRateLimiter {
constructor(private readonly redis: Redis) {}
async slidingWindowLimit(
key: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfter: number }> {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const windowStart = now - windowMs;
const pipeline = this.redis.pipeline();
// Remove expired entries
pipeline.zremrangebyscore(key, "-inf", windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set TTL to auto-cleanup
pipeline.expire(key, windowSeconds + 1);
const results = await pipeline.exec();
const requestCount = results?.[2]?.[1] as number;
if (requestCount > maxRequests) {
// Remove the request we just added
await this.redis.zremrangebyscore(key, now, now);
// Calculate retry-after from oldest request in window
const oldest = await this.redis.zrange(key, 0, 0, "WITHSCORES");
const oldestTime = oldest.length > 1 ? parseInt(oldest[1], 10) : now;
const retryAfterMs = oldestTime + windowMs - now;
return {
allowed: false,
remaining: 0,
retryAfter: Math.ceil(retryAfterMs / 1000),
};
}
return {
allowed: true,
remaining: maxRequests - requestCount,
retryAfter: 0,
};
}
}Express Middleware Implementation
Wrap the rate limiter in middleware that handles response headers, error responses, and client identification.
import { Request, Response, NextFunction } from "express";
interface RateLimitConfig {
maxRequests: number;
windowSeconds: number;
keyGenerator: (req: Request) => string;
skip?: (req: Request) => boolean;
onLimitReached?: (req: Request) => void;
}
function createRateLimitMiddleware(
limiter: RedisRateLimiter,
config: RateLimitConfig
) {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
if (config.skip?.(req)) {
next();
return;
}
const key = `ratelimit:${config.keyGenerator(req)}`;
const result = await limiter.slidingWindowLimit(
key,
config.maxRequests,
config.windowSeconds
);
// Always set rate limit headers
res.setHeader("X-RateLimit-Limit", config.maxRequests);
res.setHeader("X-RateLimit-Remaining", result.remaining);
res.setHeader(
"X-RateLimit-Reset",
Math.ceil(Date.now() / 1000) + config.windowSeconds
);
if (!result.allowed) {
res.setHeader("Retry-After", result.retryAfter);
config.onLimitReached?.(req);
res.status(429).json({
error: "Too Many Requests",
message: `Rate limit exceeded. Try again in ${result.retryAfter} seconds.`,
retryAfter: result.retryAfter,
});
return;
}
next();
};
}
// Usage with different tiers
const apiLimiter = createRateLimitMiddleware(limiter, {
maxRequests: 100,
windowSeconds: 60,
keyGenerator: (req) => req.headers["x-api-key"] as string || req.ip || "unknown",
skip: (req) => req.path === "/health",
onLimitReached: (req) => {
console.warn(`Rate limit hit: ${req.ip} on ${req.path}`);
},
});
const authLimiter = createRateLimitMiddleware(limiter, {
maxRequests: 5,
windowSeconds: 300,
keyGenerator: (req) => `auth:${req.ip}`,
});Tiered Rate Limiting
Different API consumers deserve different limits. Free tier users get lower limits; premium users get higher limits; internal services need even higher or unlimited access.
interface RateLimitTier {
name: string;
requestsPerMinute: number;
requestsPerDay: number;
burstCapacity: number;
}
const tiers: Record<string, RateLimitTier> = {
free: {
name: "Free",
requestsPerMinute: 30,
requestsPerDay: 1000,
burstCapacity: 10,
},
pro: {
name: "Professional",
requestsPerMinute: 300,
requestsPerDay: 50000,
burstCapacity: 50,
},
enterprise: {
name: "Enterprise",
requestsPerMinute: 3000,
requestsPerDay: 500000,
burstCapacity: 200,
},
};
async function getTierForApiKey(apiKey: string): Promise<RateLimitTier> {
const cached = await redis.get(`tier:${apiKey}`);
if (cached) return JSON.parse(cached);
const tier = await db.query(
"SELECT tier FROM api_keys WHERE key_hash = $1",
[hashApiKey(apiKey)]
);
const result = tiers[tier?.tier || "free"];
await redis.set(`tier:${apiKey}`, JSON.stringify(result), "EX", 300);
return result;
}Key Takeaways
Rate limiting protects your API from abuse and ensures fair access across all consumers. Choose the token bucket for APIs that benefit from burst allowances and the sliding window counter for smoother, more predictable limiting. Use Redis for distributed rate limiting so all application instances share the same counters.
Always return proper HTTP headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) so clients can self-regulate. Implement tiered rate limits that align with your pricing model—free users get lower limits, paying customers get higher limits. Separate rate limits for sensitive endpoints like authentication, which need much stricter controls than general API endpoints.
The best rate limiter is one your API consumers never notice because the limits are generous enough for normal usage while strict enough to protect against abuse.


