Rate limiting is the difference between an API that stays up under abuse and one that falls over. In 2026, the common pattern is a Redis-backed sliding window for distributed deployments, edge-level blocking for known abusers, and standardized response headers so clients can self-throttle. The good news: the libraries are mature enough that you can add solid rate limiting in an afternoon.
What changed in 2026
- Upstash Rate Limit became the go-to library for serverless/edge deployments — HTTP-based Redis, no persistent connections, works on Cloudflare Workers.
- IETF Rate Limit Headers draft was finalized; standardized headers mean clients can handle 429s programmatically.
- Edge providers baked in rate limiting — Cloudflare Rate Limiting, Vercel Firewall, and AWS WAF Managed Rules can enforce limits before your code runs.
- Durable Objects (Cloudflare) enable stateful per-user rate limiting at the edge without a separate Redis cluster.
Algorithms compared
| Algorithm |
Accuracy |
Burst handling |
Implementation complexity |
| Fixed window |
Low (boundary spikes) |
Poor |
Trivial |
| Sliding window (log) |
High |
Good |
Medium |
| Sliding window (counter) |
Good |
Good |
Low |
| Token bucket |
High |
Excellent (controlled burst) |
Medium |
| Leaky bucket |
High |
None (smooths traffic) |
Medium |
Sliding window counter is the sweet spot for most APIs: accurate enough, Redis-efficient (two operations per request), and easy to reason about.
Express + express-rate-limit + Redis store
npm install express-rate-limit @express-rate-limit/redis rate-limiter-flexible ioredis
import rateLimit from 'express-rate-limit';
import { RedisStore } from '@express-rate-limit/redis';
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const limiter = rateLimit({
windowMs: 60_000, // 1 minute
max: 100, // 100 requests per window
standardHeaders: 'draft-7', // RateLimit-* headers
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
keyGenerator: (req) => req.headers['x-api-key'] ?? req.ip,
});
app.use('/api/', limiter);
keyGenerator lets you rate-limit by API key (for authenticated routes) or IP (for public endpoints).
Upstash for serverless / edge
npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'),
analytics: true,
});
// In your edge function / API route:
const identifier = request.headers.get('x-api-key') ?? 'anonymous';
const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
if (!success) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'RateLimit-Limit': String(limit),
'RateLimit-Remaining': String(remaining),
'RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
});
}
This works on Cloudflare Workers, Vercel Edge, and Next.js Middleware with zero modifications.
Tiered limits by API key
const plans = { free: 60, pro: 600, enterprise: 6000 };
const keyGenerator = async (req) => {
const key = req.headers['x-api-key'];
const plan = await getPlanForKey(key); // DB lookup, cached
return `${plan}:${key}`;
};
const limiter = rateLimit({
max: async (req) => {
const key = req.headers['x-api-key'];
const plan = await getPlanForKey(key);
return plans[plan] ?? plans.free;
},
// ...
});
Cache the plan lookup in Redis (TTL ~5 min) to avoid hitting your DB on every request.
How to pick a strategy
- Single server, low traffic → in-memory
express-rate-limit with no store is fine.
- Multi-instance or microservices → Redis-backed sliding window; any instance sees the same counters.
- Serverless / edge → Upstash + sliding window; HTTP Redis, no persistent connection needed.
- Need burst allowance → token bucket (
rate-limiter-flexible supports it).
- Protecting login / sensitive endpoints → stricter limits (5–10 req/min) keyed by IP + username.
Common mistakes
Same limit for all endpoints. Your /login endpoint should be ~5 req/min per IP; your public search might be 1000 req/min. Differentiate by route.
Not returning 429 with headers. A bare 429 with no Retry-After header causes clients to retry immediately, compounding the load. Always include the header.
Rate-limiting by IP behind a load balancer. If X-Forwarded-For is not trusted and parsed correctly, every user appears to be the load balancer IP. Configure app.set('trust proxy', 1) in Express.
No rate-limiting on auth endpoints. Login, registration, and password-reset endpoints are prime targets for credential stuffing. These need aggressive limits and lockout logic.
What to skip
- IP blocklists as your primary defense — IPs rotate; blocklists create more maintenance than protection. Use rate limits as the primary mechanism.
- Very short windows (< 1 s) for most endpoints — they create a noisy experience for legitimate power users. 1-minute windows work for most cases.
- Client-side rate limiting — never trust it; enforce limits server-side always.
FAQ
What HTTP status code should a rate-limited response return?
429 Too Many Requests. Include Retry-After (seconds until the limit resets) and RateLimit-Remaining: 0 headers.
How do I rate-limit authenticated users differently from anonymous ones?
Use the API key or user ID as the keyGenerator for authenticated requests, and IP for anonymous ones. Apply different max values per tier.
Should I rate-limit at the application or infra level?
Both, in layers. Edge/WAF rules catch obvious abuse before your app; application-level rules enforce per-user semantics.
How do I handle distributed denial-of-service (DDoS)?
Rate limiting alone is not DDoS protection. Use a CDN/WAF (Cloudflare, AWS Shield) for volumetric attacks; rate limiting handles application-layer abuse.
Where to go next