throughput-focused systems require token replenishment models. Misalignment causes either revenue loss from false positives or infrastructure degradation from false negatives.
Core Solution
Implementing a production-grade rate limiter requires three architectural decisions:
- State backend: Redis or equivalent in-memory data store with sub-millisecond latency and TTL support
- Enforcement model: Sliding Window Log for accuracy, or Token Bucket for throughput
- Coordination mechanism: Lua scripting for atomic read-check-write operations to eliminate race conditions
The following implementation uses a Redis-backed Sliding Window Log with TypeScript, designed for Express-compatible middleware. It prioritizes atomicity, fallback resilience, and standard header compliance.
Step 1: Redis Client & Connection Pool Configuration
import { createClient, RedisClientType } from 'redis';
const redisClient: RedisClientType = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
socket: { reconnectStrategy: (retries) => Math.min(retries * 50, 2000) },
poolSize: 10,
});
await redisClient.connect();
Step 2: Atomic Lua Script for Sliding Window Enforcement
Redis executes Lua scripts atomically, eliminating TOCTOU (time-of-check-time-of-use) races across distributed nodes.
-- sliding_window.lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local request_id = ARGV[4]
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window_ms)
-- Count current requests in window
local current = redis.call('ZCARD', key)
if current < limit then
-- Add new request with timestamp as score
redis.call('ZADD', key, now, request_id)
-- Set TTL to auto-expire key after window closes
redis.call('PEXPIRE', key, window_ms)
return {1, limit - current - 1, current + 1}
else
-- Limit exceeded
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')[2]
local retry_after_ms = tonumber(oldest) + window_ms - now
return {0, 0, current, math.max(retry_after_ms, 0)}
end
Step 3: TypeScript Middleware Implementation
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
keyPrefix: string;
identifierExtractor: (req: Request) => string;
}
export function rateLimiter(config: RateLimitConfig) {
return async (req: Request, res: Response, next: NextFunction) => {
const identifier = config.identifierExtractor(req);
const key = `${config.keyPrefix}:${identifier}`;
const now = Date.now();
const requestId = randomUUID();
try {
const result = await redisClient.eval(
`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local request_id = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window_ms)
local current = redis.call('ZCARD', key)
if current < limit then
redis.call('ZADD', key, now, request_id)
redis.call('PEXPIRE', key, window_ms)
return {1, limit - current - 1, current + 1}
else
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')[2]
local retry_after_ms = tonumber(oldest) + window_ms - now
return {0, 0, current, math.max(retry_after_ms, 0)}
end
`,
{ keys: [key], arguments: [now.toString(), config.windowMs.toString(), config.maxRequests.toString(), requestId] }
);
const [allowed, remaining, total] = result as number[];
const retryAfter = result[3] as number | undefined;
res.set('RateLimit-Limit', config.maxRequests.toString());
res.set('RateLimit-Remaining', Math.max(remaining, 0).toString());
res.set('RateLimit-Reset', Math.ceil((now + config.windowMs) / 1000).toString());
if (!allowed && retryAfter !== undefined) {
res.set('Retry-After', Math.ceil(retryAfter / 1000).toString());
}
if (!allowed) {
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: retryAfter ? Math.ceil(retryAfter / 1000) : undefined,
});
}
next();
} catch (err) {
// Fallback: allow request but log failure for observability
console.error('[RateLimiter] Redis failure, allowing request:', err);
res.set('RateLimit-Fallback', 'true');
next();
}
};
}
Step 4: Usage & Tiered Configuration
app.use('/api/v1', rateLimiter({
windowMs: 60_000,
maxRequests: 100,
keyPrefix: 'rl:api',
identifierExtractor: (req) => req.headers['x-api-key'] || req.ip,
}));
Architecture decisions rationale:
- Lua atomicity: Prevents double-counting when multiple nodes evaluate limits simultaneously. Without it,
ZCARD and ZADD execute as separate commands, allowing limit bypass under concurrency.
- Sorted sets with timestamps: Enables O(log N) cleanup and precise window enforcement. Memory scales linearly with requests per window, which is acceptable for standard API tiers.
- PEXPIRE on first write: Guarantees key eviction without background cleanup jobs. Redis handles TTL natively, eliminating drift.
- Fallback behavior: On Redis partition or timeout, the limiter allows traffic but sets
RateLimit-Fallback: true. This prioritizes availability over strict enforcement, aligning with graceful degradation principles.
- Standard headers:
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After comply with IETF draft standards, enabling client-side backoff without custom logic.
Pitfall Guide
-
In-memory counters in horizontally scaled deployments
- Why it fails: Each node maintains isolated state. A 100 req/min limit across 3 nodes becomes 300 req/min effectively. Load balancers distribute requests unevenly, causing unpredictable enforcement.
- Best practice: Externalize state to Redis, Memcached, or a dedicated rate-limiting service. Use consistent hashing on API keys or tenant IDs to route to the same shard when possible.
-
Ignoring distributed clock skew
- Why it fails: Node clocks drift by 10β50ms under load. Window boundaries misalign, causing duplicate counting or premature expiration.
- Best practice: Rely on Redis server time (
TIME command) or generate timestamps client-side but validate against Redis monotonic counters. Avoid system Date.now() for window calculations in distributed setups.
-
Hardcoding limits without tiering or adaptive logic
- Why it fails: Public endpoints, authenticated users, and internal services have different risk profiles. Uniform limits either choke legitimate traffic or leave attack surfaces open.
- Best practice: Implement tiered limits via configuration maps or database lookups. Use
x-api-key or JWT claims to resolve limits dynamically. Cache resolved tiers for 30β60 seconds to avoid DB hits per request.
-
Blocking instead of queuing or throttling
- Why it fails: Returning 429 immediately drops traffic without providing recovery paths. Clients retry instantly, amplifying load during outages.
- Best practice: Expose
Retry-After headers with exponential backoff guidance. For internal services, implement token bucket with queueing or circuit breakers that degrade gracefully instead of hard-failing.
-
Missing standard compliance headers
- Why it fails: Clients cannot implement intelligent backoff. Monitoring systems lack visibility into limit consumption. Debugging becomes trial-and-error.
- Best practice: Always return
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After (when applicable). Align with IETF draft-ietf-httpapi-ratelimit-headers for cross-platform compatibility.
-
Over-provisioning precision for the workload
- Why it fails: Sliding Window Log with millisecond precision consumes excessive memory and CPU for low-traffic APIs. Token bucket with microsecond replenishment adds unnecessary complexity.
- Best practice: Match algorithm to traffic profile. Use Fixed Window for internal microservices, Token Bucket for public APIs, Sliding Window Log for financial or compliance-critical endpoints. Monitor Redis memory usage and adjust window sizes accordingly.
-
Coupling rate limiting with authentication
- Why it fails: Authenticated requests bypass IP-based limits, creating privilege escalation paths. Unauthenticated endpoints become attack vectors while authenticated ones remain unprotected.
- Best practice: Apply rate limiting at the identity layer, not the transport layer. Extract limits from JWT claims, API keys, or tenant metadata. Enforce limits before authentication validation to prevent credential-stuffing amplification.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-instance monolith | In-memory Fixed Window | Zero network overhead, sufficient for non-distributed workloads | Negligible |
| Multi-region public API | Token Bucket with Redis | Predictable throughput, low memory footprint, handles burst tolerance | Moderate (Redis egress) |
| Financial/compliance endpoint | Sliding Window Log with Lua | Near-perfect enforcement, audit-ready request tracking | High (sorted set memory + CPU) |
| Cost-sensitive SaaS with tiered plans | Hybrid: Fixed Window + Redis TTL | Balances precision with memory efficiency, supports tenant isolation | Low-Moderate |
| Internal microservice mesh | Leaky Bucket or Token Bucket | Enforces steady-state output, prevents cascade failures | Negligible |
Configuration Template
// rate-limit.config.ts
export const rateLimitConfig = {
global: {
windowMs: 60_000,
maxRequests: 200,
keyPrefix: 'rl:global',
fallback: 'allow',
headers: true,
},
tiers: {
free: { windowMs: 60_000, maxRequests: 50, keyPrefix: 'rl:free' },
pro: { windowMs: 60_000, maxRequests: 500, keyPrefix: 'rl:pro' },
enterprise: { windowMs: 60_000, maxRequests: 2000, keyPrefix: 'rl:ent' },
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
socketTimeout: 50,
retryLimit: 3,
poolSize: 10,
},
observability: {
metricsPrefix: 'rate_limit',
logLevel: 'warn',
alertThreshold: 0.8, // Alert at 80% limit consumption
},
};
Quick Start Guide
- Install dependencies:
npm install express redis @types/express
- Configure Redis: Set
REDIS_URL environment variable pointing to a Redis 6+ instance with sorted set support
- Drop in middleware: Import
rateLimiter from the core solution, configure identifierExtractor to use req.ip or API key header
- Apply to routes: Attach middleware to Express/NestJS route handlers or global app instance
- Validate: Run
curl -H "x-api-key: test" http://localhost:3000/api/v1/data repeatedly; verify RateLimit-Remaining decrements and 429 returns with Retry-After after threshold