An API without rate limiting is an open door. Any client can make thousands of requests per second, crash the server, or consume resources other users need. But excessive rate limiting blocks legitimate users using the API normally.
Rate limiting algorithms
Three approaches dominate practice:
Fixed Window
Counts requests within a fixed window (e.g., 100 requests per minute). Simple, but has the "edge burst" problem: 100 requests at second 59 of the window + 100 at second 0 of the next = 200 requests in 2 seconds.
Sliding Window Log
Keeps the timestamp of each request in the window. More accurate, but consumes memory proportional to the number of requests.
Token Bucket
The most used in production. Tokens are added at regular intervals. Each request consumes a token. When tokens run out, the request is rejected. Allows controlled bursts.
// Token bucket with Redis
async function isRateLimited(
key: string,
maxTokens: number,
refillRate: number
): Promise<boolean> {
const now = Date.now()
const bucket = await redis.hgetall(`ratelimit:${key}`)
if (!bucket.tokens) {
// First request: create bucket
await redis.hset(`ratelimit:${key}`, {
tokens: maxTokens - 1,
lastRefill: now
})
await redis.expire(`ratelimit:${key}`, 60)
return false
}
// Refill tokens based on elapsed time
const elapsed = now - Number(bucket.lastRefill)
const refill = Math.floor(elapsed / 1000 * refillRate)
const tokens = Math.min(maxTokens, Number(bucket.tokens) + refill)
if (tokens <= 0) return true // rate limited
await redis.hset(`ratelimit:${key}`, {
tokens: tokens - 1,
lastRefill: now
})
return false
}
By key: IP, user, or API key
The rate limit key defines who is being limited:
- By IP: protects against bots and anonymous abuse. Basic, but doesn't differentiate legitimate users from shared IPs (CGNAT, VPNs).
- By authenticated user: fairer. Users pay by plan, and rate limit follows the plan.
- By API key: for public APIs. Each developer gets their bucket, and abuse doesn't affect others.
- Compound: global limit by IP + individual limit by user. Protects against DDoS and abuse simultaneously.
HTTP headers that communicate state
Clients need to know when they're approaching the limit. These headers are market standard:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 23
X-RateLimit-Reset: 1711234567
Retry-After: 30 // when returning 429
Retry-After is mandatory in 429 responses. Without it, the client doesn't know when to try again and may enter a retry loop.
Implementation with express-rate-limit
For Node.js, the standard implementation:
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res) => {
res.status(429).json({
error: {
code: 'RATE_LIMITED',
message: 'Too many requests. Try again later.',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
}
})
}
})
app.use('/api/', limiter)
Route-specific rate limiting
Not every route has the same cost. Login is expensive (bcrypt). Listings are cheap. Apply different limits:
// Login: 5 attempts per minute (protects against brute force)
app.use('/api/auth/login', rateLimit({ windowMs: 60000, max: 5 }))
// Listings: 100 per minute
app.use('/api/products', rateLimit({ windowMs: 60000, max: 100 }))
// Upload: 10 per hour
app.use('/api/upload', rateLimit({ windowMs: 3600000, max: 10 }))
What to do when rate limited
Beyond 429 with Retry-After, implement graceful degradation: cache the response longer, or return partial data when available. The user gets something instead of a pure error.
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk