What Is Rate Limiting?
Rate limiting controls how many requests a client can make to an API within a time window. Without it, a single misbehaving client can flood your server, degrading service for everyone else.
Why rate limiting matters:
- Protects against DoS/DDoS attacks
- Prevents API abuse and scraping
- Ensures fair resource distribution among clients
- Reduces infrastructure costs
- Catches accidental bugs (tight loops hitting your API)
Rate Limiting Algorithms
1. Fixed Window Counter
Count requests in a fixed time window (e.g., each 60-second minute starting at :00).
async function isRateLimited(clientId, limit = 100, windowSeconds = 60) {
const key = `rate:${clientId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSeconds);
return count > limit;
}
Problem: Boundary burst — client can make 100 requests at 1:59 and 100 more at 2:00, sending 200 in 2 seconds.
2. Sliding Window Counter (Recommended)
Approximates a true sliding window using weighted counts from current + previous window:
async function isRateLimitedSliding(clientId, limit = 100, windowSeconds = 60) {
const now = Math.floor(Date.now() / 1000);
const currWindow = Math.floor(now / windowSeconds);
const prevWindow = currWindow - 1;
const elapsed = now % windowSeconds;
const [prevCount, currCount] = await redis.mget(
`rate:${clientId}:${prevWindow}`,
`rate:${clientId}:${currWindow}`
);
const weighted = (Number(prevCount) || 0) * (1 - elapsed / windowSeconds)
+ (Number(currCount) || 0);
if (weighted >= limit) return true;
const pipeline = redis.pipeline();
pipeline.incr(`rate:${clientId}:${currWindow}`);
pipeline.expire(`rate:${clientId}:${currWindow}`, windowSeconds * 2);
await pipeline.exec();
return false;
}
3. Token Bucket
Bucket holds N tokens. Requests consume tokens. Bucket refills at a fixed rate. Allows short bursts while maintaining average rate.
// Token bucket in Redis using Lua (atomic)
const script = `
local key, capacity, rate, now = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)
if tokens < 1 then return 0 end
redis.call('HMSET', key, 'tokens', tokens - 1, 'ts', now)
redis.call('EXPIRE', key, 3600)
return 1
`;
async function isAllowed(clientId) {
return (await redis.eval(script, 1,
`bucket:${clientId}`, 100, 1.67, Date.now()/1000)) === 1;
}
Rate Limit HTTP Headers
RateLimit-Limit: 100 # Max requests per window
RateLimit-Remaining: 42 # Remaining in this window
RateLimit-Reset: 1716854460 # Unix timestamp when window resets
Retry-After: 30 # Seconds to wait (on 429 response)
Express Middleware
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
// Global rate limit
app.use(rateLimit({
windowMs: 60 * 1000, max: 100,
standardHeaders: true, legacyHeaders: false,
store: new RedisStore({ client: redis }),
}));
// Stricter for sensitive endpoints
app.use('/auth/login', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true,
}));
Client-Side: Handle 429 with Retry
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i <= maxRetries; i++) {
const res = await fetch(url, options);
if (res.status !== 429) return res;
const delay = (parseInt(res.headers.get('Retry-After')) || Math.pow(2, i)) * 1000;
await new Promise(r => setTimeout(r, delay));
}
throw new Error('Max retries exceeded');
}
Rate Limiting Strategies
- By IP: Simple but easy to bypass with proxies. For anonymous access.
- By API key: More reliable, requires authentication.
- By endpoint: Expensive operations get stricter limits (search: 10/min, reads: 1000/min).
- Tiered: Different limits per subscription level (free/pro/enterprise).
→ Generate secure API key tokens with the Token Generator.