Rate Limiting
Rate limiting is a fundamental traffic control technique that limits the number of requests a client can make to an API or web service within a specific time window, protecting infrastructure against overload, denial-of-service attacks (DoS/DDoS), automated abuse, malicious scraping and improper use of limited computational resources. In a scenario where modern APIs serve millions of requests per second coming from mobile applications, partner integrations, legitimate bots and potentially malicious attackers, the absence of adequate rate limiting can result in explosive operational costs, severe performance degradation for legitimate users, vulnerabilities to credential stuffing and brute force attacks, and even total service unavailability. Effective rate limiting implementation requires a deep understanding of different algorithms (token bucket, leaky bucket, fixed window, sliding window), considerations about distributed architecture where multiple servers need to share rate counters, client identification strategies (IP, API key, JWT, session), differentiated policies by user tier (free, premium, enterprise), and mechanisms for clear communication through standardized HTTP headers that inform clients about limits, current consumption and reset time. This article explores algorithms, implementation patterns, tools and best practices for building robust rate limiting systems.
Rate Limiting Algorithms
1. Token Bucket (Most Common)
# Concept: Bucket with maximum token capacity
# Tokens are added at a constant rate
# Each request consumes 1 token
# If bucket empty, request is rejected
Capacity: 100 tokens
Refill rate: 10 tokens/second
Advantages:
- Allows controlled bursts (full bucket)
- Simple to implement
- Flexible for different traffic patterns
Disadvantages:
- May allow bursts that overload the system
- Requires tracking of last refill
Implementation:
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.tokens = capacity;
this.refillRate = refillRate;
this.lastRefill = Date.now();
}
consume(count = 1) {
this.refill();
if (this.tokens >= count) {
this.tokens -= count;
return true;
}
return false;
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
const tokensToAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
2. Leaky Bucket
# Concept: Queue with constant leak
# Requests enter the bucket (queue)
# Processed at a constant rate (leak)
# If queue full, requests are rejected
Advantages:
- Smooths out bursts (traffic shaping)
- Constant and predictable output rate
- Protects backend from spikes
Disadvantages:
- May add latency (queueing)
- Implementation complexity
Usage:
- Traffic shaping
- Network gateways
- When constant output rate is critical
3. Fixed Window Counter
# Concept: Counter per fixed time window
# Example: 100 requests per minute
# Reset at the start of each minute (XX:00, XX:01, XX:02...)
Advantages:
- Extremely simple
- Memory efficient
- Easy to understand
Disadvantages:
- Edge case: 200 requests in 1 second
(100 at the end of minute 1, 100 at the start of minute 2)
- Allows bursts at window boundaries
Redis implementation:
INCR user:123:2024-01-15:14:30
EXPIRE user:123:2024-01-15:14:30 60
GET user:123:2024-01-15:14:30 # If > 100, reject
4. Sliding Window Log
# Concept: Log of request timestamps
# Removes requests outside the window
# Counts requests within the sliding window
Advantages:
- Perfect accuracy
- No fixed window edge cases
- Distributes rate uniformly
Disadvantages:
- Memory intensive (stores all timestamps)
- Performance degrades with high traffic
Redis implementation (Sorted Set):
ZADD user:123 <timestamp> <request-id>
ZREMRANGEBYSCORE user:123 0 <timestamp-60s> # Remove old ones
ZCARD user:123 # Count requests
If > 100, reject
5. Sliding Window Counter (Hybrid)
# Concept: Combines fixed window with sliding
# Uses counters from previous windows with weight
# Estimates rate in a sliding window
Example: Limit 100/minute
Current window (14:30): 70 requests
Previous window (14:29): 90 requests
Elapsed in current window: 40s (66.7%)
Estimate: 90 * (1 - 0.667) + 70 = 30 + 70 = 100
Advantages:
- Accuracy close to sliding log
- Memory efficient (only 2 counters)
- Smooths out bursts
Disadvantages:
- Estimate (not exact)
- More complex than fixed window
Distributed Rate Limiting
# Problem: Multiple servers need to share state
# Solutions:
1. Centralized Redis (Most Common)
const redis = require('redis');
const client = redis.createClient();
async function checkRateLimit(userId) {
const key = \`rate:\$:\${getCurrentWindow()}\`;
const count = await client.incr(key);
if (count === 1) {
await client.expire(key, 60); // 60 seconds
}
return count <= 100; // Limit: 100/min
}
2. Redis Lua Script (Atomic)
const luaScript = \`
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call('incr', key)
if current == 1 then
redis.call('expire', key, ARGV[2])
end
if current > limit then
return 0
end
return 1
\`;
3. Sticky Sessions + Local Counters
- Always route user to the same server
- Local counter on the server
- Problem: Does not work well with auto-scaling
4. Gossip Protocol
- Servers share state via gossip
- Eventual consistency
- More complex, used at high scale
Standard HTTP Headers
# Standards (RFCs)
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1640000000 # Unix timestamp
# When limit exceeded
HTTP/1.1 429 Too Many Requests
Retry-After: 60 # Seconds until retry
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640000060
{
"error": "Rate limit exceeded",
"retryAfter": 60,
"limit": 100
}
# GitHub style (more informative)
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4999
X-RateLimit-Reset: 1372700873
X-RateLimit-Used: 1
X-RateLimit-Resource: core
Implementation with Express.js
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');
const client = redis.createClient();
// Basic rate limiter
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
message: 'Too many requests, please try again later.'
});
app.use('/api/', limiter);
// Redis-based distributed rate limiting
const distributedLimiter = rateLimit({
store: new RedisStore({
client: client,
prefix: 'rate-limit:',
}),
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
});
// Different limits per route
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Stricter for auth endpoints
skipSuccessfulRequests: true, // Don't count successful logins
});
app.post('/api/login', authLimiter, loginHandler);
// Custom key function (rate limit by user ID instead of IP)
const userLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
keyGenerator: (req) => req.user.id, // Requires auth middleware
});
Tiered Rate Limiting
// Different limits based on user tier
function getRateLimit(user) {
const tiers = {
free: { windowMs: 3600000, max: 100 }, // 100/hour
basic: { windowMs: 3600000, max: 1000 }, // 1000/hour
premium: { windowMs: 3600000, max: 10000 }, // 10k/hour
enterprise: { windowMs: 3600000, max: 100000 } // 100k/hour
};
return tiers[user.tier] || tiers.free;
}
app.use(async (req, res, next) => {
const user = await getUserFromToken(req);
const limits = getRateLimit(user);
const limiter = rateLimit({
...limits,
keyGenerator: () => user.id,
});
limiter(req, res, next);
});
Tools and Services
- Redis: Distributed counters, automatic expire
- Kong: API Gateway with rate limiting plugin
- Nginx rate limiting: limit_req_zone, limit_conn_zone
- Cloudflare: CDN-level rate limiting
- AWS API Gateway: Built-in throttling
- express-rate-limit: Express middleware
- Tyk: Open-source API gateway
Best Practices
- Choose the right algorithm: Token bucket for general APIs, sliding window for accuracy
- Differentiated limits: Auth endpoints more restrictive, read-only more permissive
- Clear communication: Informative headers, useful error messages
- Whitelist: IPs of trusted partners, health checks
- Monitoring: Alert when users frequently hit limits
- Graceful degradation: Return cached data if possible
- Distributed state: Use Redis for multi-server deployments
- Cost-based limiting: Expensive operations consume more tokens
Recommendations
For modern APIs, implement token bucket with Redis for distributed rate limiting. Use sliding window counter when you need accuracy without memory overhead. Configure differentiated limits: 5 req/min for login, 100 req/min for reads, 10 req/min for write operations. Always return informative headers and implement retry logic with exponential backoff on clients.
