Most Node.js rate-limiting tutorials hand you something like express-rate-limit with its default in-memory store, wish you luck, and call it a day. That works fine on a single server. The moment you scale horizontally — two instances behind a load balancer — each server maintains its own independent counter. A single client can now hammer your API at N × limit requests, where N is your instance count. Nobody warns you about this. It just silently fails in production.
This article is about fixing that properly.
Why In-Memory Rate Limiting Is a Hidden Trap
The failure mode is subtle because it doesn't look like a failure during local development or even single-instance staging. Your rate limiter appears to work. Tests pass. It only breaks when you do the right thing — scale out.
Consider a typical setup:
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per window
});
app.use('/api/', limiter);
With three server instances behind a round-robin load balancer, a determined client cycles across all three and effectively gets 300 requests per minute. Your DDoS protection just became decoration.
There is a second, quieter problem: restarts reset the counters. Auto-scaling events, container restarts, and deployments all silently wipe every rate-limit window in progress. Clients get a free pass every time your infrastructure breathes.
The Right Mental Model: Shared State
A distributed rate limiter needs one thing above all else — a single source of truth that every instance reads from and writes to atomically. Redis is the canonical choice for this because it offers:
- Atomic increment operations via
INCRandINCRBY - Key expiration with
EXPIREorEXPIREAT, mapping naturally to time windows - Sub-millisecond latency that doesn't meaningfully add to request overhead
- Cluster and Sentinel support for high availability
The architecture is straightforward: your Node.js instances never hold rate-limit state locally. Every request hits Redis, increments a counter keyed to the client identity and the current window, and either proceeds or is rejected.
Building the Redis-Backed Rate Limiter
Choosing the Right Algorithm
Before writing any code, choose your algorithm. Three options dominate production use:
Fixed Window Counter — simplest. Divide time into fixed buckets (e.g., each minute). Count requests per bucket per client. Fast and cheap in Redis, but vulnerable to burst exploitation at window boundaries: a client can fire 100 requests at 11:59 and 100 more at 12:00.
Sliding Window Log — stores a timestamp for every request and counts those within the last N seconds. Precise, but memory-intensive at scale.
Sliding Window Counter — a hybrid that approximates the sliding window using two fixed-window buckets and a weighted interpolation. Accurate enough for most APIs, cheap in Redis, and the approach recommended for general use.
For most SaaS APIs, the sliding window counter hits the right balance. It's what Redis-backed libraries like rate-limiter-flexible implement under the hood.
Wiring It Up with rate-limiter-flexible
import { RateLimiterRedis } from 'rate-limiter-flexible';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl_api',
points: 100, // requests allowed
duration: 60, // per 60 seconds
blockDuration: 30, // block for 30s after limit breach
});
export async function rateLimitMiddleware(req, res, next) {
const key = req.user?.id ?? req.ip; // prefer authenticated ID over IP
try {
const result = await rateLimiter.consume(key);
res.set({
'X-RateLimit-Limit': 100,
'X-RateLimit-Remaining': result.remainingPoints,
'X-RateLimit-Reset': new Date(Date.now() + result.msBeforeNext).toISOString(),
});
next();
} catch (rejection) {
res.set('Retry-After', Math.ceil(rejection.msBeforeNext / 1000));
res.status(429).json({ error: 'Too many requests. Please slow down.' });
}
}
A few things worth noting here:
- Key by user ID, not just IP. Authenticated endpoints should key on the user's identity. IP-based limiting is blunt and breaks badly behind NATs or shared WiFi where dozens of legitimate users share one IP.
- Always return rate-limit headers.
X-RateLimit-RemainingandRetry-Afterare not optional courtesies — they are what well-behaved API clients use to back off gracefully. Omitting them turns every 429 into a mystery. - Set a
blockDurationdeliberately. A short block after limit breach discourages hammering without punishing legitimate users for too long.
Tiered Limits for Real-World APIs
Flat global limits are rarely the right answer for a SaaS product. Production APIs need differentiated limits:
- By endpoint sensitivity — a
/loginor/forgot-passwordendpoint should have a much stricter limit than a/productslisting endpoint. Credential-stuffing attacks target auth routes specifically. - By subscription tier — free-tier users get 100 requests/minute; paid users get 1,000. This is both a security control and a monetisation lever.
- By operation cost — a bulk-export endpoint that triggers heavy database queries should be rate-limited far more aggressively than a lightweight status check.
Implement this by composing multiple RateLimiterRedis instances and running them as a chain of middleware — each enforcing its own rule — before the request reaches your route handler.
Operational Considerations
What Happens If Redis Goes Down?
You have two options: fail open (allow all traffic through) or fail closed (block all traffic). Neither is universally correct. For most APIs, failing open is the safer default — a brief window of unprotected traffic is preferable to taking your entire API offline. Wrap your Redis consume call in a try/catch that distinguishes rate-limit rejections from Redis connection errors, and route the latter to a fallback that calls next() with an alert logged.
Redis Key Expiration as Your Safety Net
Always verify that your Redis instance has maxmemory-policy set to allkeys-lru or volatile-lru. Without an eviction policy, a Redis instance that fills up will start refusing writes — including your rate-limit increments — and silently fail open anyway.
Monitoring
Track these metrics:
- 429 response rate per endpoint and per client
- Redis command latency (alert above 5ms)
- Rate-limiter key cardinality (unusually high counts can indicate key-enumeration attacks)
Why This Matters for Your Project
If you are building a SaaS API on Node.js and deploying to any environment that runs more than one container — Kubernetes, ECS, Railway, Render — the in-memory rate limiter you copied from the quickstart docs is not protecting you. A Redis-backed, distributed limiter is not premature optimisation; it is the baseline for any API that serves real users. Getting this right early means you never have to scramble to patch a rate-limiting hole while an attacker is actively exploiting it at 3 a.m.




