tion systems should standardize on Token Bucket or Sliding Window Counter patterns with atomic enforcement to minimize drift and cost.
Core Solution
Architecture Decision: Atomic Lua Execution
At scale, the rate limit check and update must be atomic. A separate GET followed by SET or INCR introduces race conditions where concurrent requests read the same count before any update occurs.
The solution is to offload the logic to the storage backend using atomic scripts. For Redis, Lua scripts execute atomically within the single-threaded event loop, guaranteeing consistency without distributed locks.
Step-by-Step Implementation
1. Define the Token Bucket Model
The token bucket algorithm allows a defined rate of consumption and a maximum burst capacity.
- Capacity: Maximum tokens in the bucket.
- Refill Rate: Tokens added per second.
- Cost: Tokens consumed per request.
2. Atomic Lua Script
This script handles refill calculation, availability check, and consumption in a single atomic operation.
-- rate_limit.lua
-- KEYS[1]: rate limit key
-- ARGV[1]: capacity (max tokens)
-- ARGV[2]: refill rate (tokens per second)
-- ARGV[3]: current timestamp (milliseconds)
-- ARGV[4]: cost (tokens to consume)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
-- Initialize bucket if new
if tokens == nil then
tokens = capacity
last_refill = now
end
-- Calculate refill
local elapsed = (now - last_refill) / 1000.0
local new_tokens = math.min(capacity, tokens + (elapsed * refill_rate))
-- Check availability
local allowed = 0
local remaining = new_tokens
if new_tokens >= cost then
new_tokens = new_tokens - cost
allowed = 1
remaining = new_tokens
end
-- Update state
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
-- Set expiry to clean up idle keys (capacity / refill_rate * 2)
local ttl = math.ceil((capacity / refill_rate) * 2)
redis.call('EXPIRE', key, ttl)
-- Return: allowed (0/1), remaining tokens, retry-after (seconds)
local retry_after = 0
if allowed == 0 then
retry_after = (cost - new_tokens) / refill_rate
end
return {allowed, math.floor(remaining), math.ceil(retry_after)}
3. TypeScript Wrapper
The application code manages key generation, error handling, and header propagation.
import { Redis } from 'ioredis';
import { readFileSync } from 'fs';
import { join } from 'path';
export interface RateLimitResult {
allowed: boolean;
remaining: number;
limit: number;
retryAfter: number; // seconds
}
export class DistributedRateLimiter {
private redis: Redis;
private luaScript: string;
constructor(redis: Redis) {
this.redis = redis;
this.luaScript = readFileSync(join(__dirname, 'rate_limit.lua'), 'utf8');
}
async consume(
identifier: string,
capacity: number,
refillRate: number,
cost: number = 1
): Promise<RateLimitResult> {
const key = `rl:${identifier}`;
const now = Date.now();
try {
// EVALSHA is preferred in production; load script once on startup
const result = await this.redis.eval(
this.luaScript,
1,
key,
capacity,
refillRate,
now,
cost
);
const [allowed, remaining, retryAfter] = result as number[];
return {
allowed: allowed === 1,
remaining,
limit: capacity,
retryAfter,
};
} catch (error) {
// Critical: Fail-open or fail-closed decision based on policy
console.error('Rate limiter failure:', error);
return this.handleFailure();
}
}
private handleFailure(): RateLimitResult {
// Strategy: Fail-closed for security-critical APIs
// Strategy: Fail-open for non-critical throughput
return { allowed: false, remaining: 0, limit: 0, retryAfter: 60 };
}
}
4. Hierarchical Limiting Strategy
Single-dimensional limits are insufficient. Implement hierarchical checks:
- Global Limit: Protects the entire system (e.g., 10k RPS).
- Tenant/User Limit: Enforces subscription tiers.
- Endpoint Limit: Protects expensive operations.
Order checks from most restrictive to least restrictive to fail fast and reduce Redis load.
Rationale
- Atomicity: Lua scripts eliminate race conditions without external locking.
- Statelessness: Application nodes require no shared state; Redis is the source of truth.
- Efficiency:
EXPIRE ensures memory reclamation for inactive keys.
- Flexibility: The script supports variable costs, enabling weighted rate limiting (e.g., search costs 5x more than read).
Pitfall Guide
1. Non-Atomic Check-Update Patterns
Mistake: Using GET to check count and INCR to update.
Impact: Race conditions allow multiple requests to pass before the count increments.
Fix: Use Lua scripts or Redis INCR with EXPIRE for counter-based limits. Never split the check and update.
2. Key Cardinality Explosion
Mistake: Creating unique keys for every IP/Endpoint/User combination without aggregation.
Impact: Redis memory exhaustion and eviction of other critical data.
Fix: Implement key aggregation strategies. Use sampling for high-cardinality dimensions. Set aggressive TTLs. Monitor key count metrics.
3. Thundering Herd on Limit Reset
Mistake: Fixed windows reset simultaneously, causing a burst of requests.
Impact: Backend overload immediately after the window resets.
Fix: Use Token Bucket algorithms which smooth traffic. If using windows, add jitter to reset times or implement "soft" limits that gradually increase capacity.
4. Clock Skew in Distributed Systems
Mistake: Relying on local node time for window calculations.
Impact: Inconsistent enforcement across nodes; requests may be allowed or denied based on node clock drift.
Fix: Use a synchronized time source or rely on the storage backend's monotonic clock where possible. For Redis, Date.now() is generally acceptable if NTP is configured, but ensure all nodes are synced.
5. Fail-Open Security Risks
Mistake: Returning allowed: true when the rate limiter fails.
Impact: During Redis outages, the system becomes unprotected against abuse.
Fix: Define a failure policy. Critical APIs should fail-closed. Non-critical APIs may fail-open. Implement circuit breakers to detect limiter failures.
6. Missing Standard Headers
Mistake: Not returning X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After.
Impact: Clients cannot implement backoff strategies, leading to aggressive retry loops that worsen load.
Fix: Always return standard headers. Implement Retry-After accurately based on the algorithm state.
7. Single Point of Failure
Mistake: Using a single Redis instance for rate limiting.
Impact: Redis failure takes down the rate limiter, potentially halting all traffic.
Fix: Deploy Redis Cluster or Sentinel. Ensure the application can handle connection errors gracefully.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Low Traffic, Simple Limits | In-Memory Counter + Local Cache | Minimal infrastructure; zero network latency. | Low (Compute only) |
| High Burst, Fair Usage | Token Bucket (Redis Lua) | Smooths bursts; atomic enforcement; scalable. | Medium (Redis cost) |
| Strict Accuracy Required | Sliding Window Log (Redis) | Exact count of requests in window. | High (Memory overhead) |
| Multi-Region Global Limit | Distributed Token Bucket (CRDT) | Eventual consistency without coordination latency. | High (Complexity/Compute) |
| Cost-Sensitive, Approximate | Sliding Window Counter (Redis) | Low memory; good accuracy with minor drift. | Low-Medium |
Configuration Template
Use this YAML structure to define rate limit policies dynamically. This can be consumed by a configuration service or loaded at startup.
rate_limits:
global:
capacity: 10000
refill_rate: 10000 # 10k per second
window: 1s
tenants:
default:
capacity: 100
refill_rate: 10 # 10 per second
burst: 20
premium:
capacity: 1000
refill_rate: 100
burst: 200
endpoints:
/api/search:
cost: 5
capacity: 50
refill_rate: 5
/api/data:
cost: 1
capacity: 1000
refill_rate: 100
failure_policy: fail_closed
headers:
include_limit: true
include_remaining: true
include_retry_after: true
Quick Start Guide
-
Initialize Redis:
docker run -d -p 6379:6379 redis:7-alpine
-
Load Lua Script:
Save rate_limit.lua and load it into Redis using SCRIPT LOAD to get the SHA1 hash for EVALSHA calls.
-
Create Rate Limiter Instance:
const redis = new Redis();
const limiter = new DistributedRateLimiter(redis);
-
Integrate Middleware:
app.use(async (req, res, next) => {
const key = `user:${req.user.id}`;
const result = await limiter.consume(key, 100, 10);
res.set('X-RateLimit-Limit', result.limit.toString());
res.set('X-RateLimit-Remaining', result.remaining.toString());
if (!result.allowed) {
res.set('Retry-After', result.retryAfter.toString());
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
-
Verify:
Send rapid requests and observe headers. Confirm 429 responses when limit is reached and accurate Retry-After values.
Conclusion
Rate limiting at scale requires moving beyond simple counters to distributed, atomic enforcement mechanisms. The Token Bucket algorithm implemented via Lua scripts provides the most robust foundation for high-throughput systems, balancing accuracy, performance, and burst handling.
Success depends on rigorous attention to atomicity, key cardinality management, and failure policies. Implement hierarchical limits, expose standard headers, and monitor enforcement metrics to maintain system health under variable load. Treat the rate limiter as a critical control plane component, not an optional feature.