By the end of this you will have an express rate limit that survives a restart and a second instance: express-rate-limit in front of your routes, a Redis-backed store so every process shares one counter, keys that are not the raw client IP, and RateLimit headers a well-behaved client can read to back off. You will also have a clear line between what a rate limiter does and the thing you probably came here worried about.
Prerequisites: Node 18 or newer, an Express 4 or 5 app you can restart, and from step 4 on a Redis instance reachable from every process. Versions here are express-rate-limit 8.7.0 and rate-limit-redis 6.0.1, which declares a peer dependency on express-rate-limit >= 8.6.0.
1. Five lines, one 429
Install and put the limiter in front of the routes you care about.
npm install express-rate-limit
import express from 'express'
import { rateLimit } from 'express-rate-limit'
const app = express()
const apiLimiter = rateLimit({
windowMs: 60_000,
limit: 60,
standardHeaders: 'draft-8',
legacyHeaders: false,
})
app.use('/api', apiLimiter)
app.get('/api/ping', (req, res) => res.json({ ok: true }))
app.listen(3000)
limit is the option name in v7 and later; older tutorials say max, which still works. The default store is a fixed window: the counter for a key starts when that key's first request arrives and is thrown away windowMs later. Nothing rolls. A client that spends its 60 requests in the last second of one window and 60 more in the first second of the next has made 120 requests in two seconds and broken no rule.
Verify it:
for i in $(seq 1 65); do curl -s -o /dev/null -w "%{http_code} " localhost:3000/api/ping; done
You should see 200 sixty times, then 429 with the default body, Too many requests, please try again later.
2. Trust proxy, or you are rate limiting your load balancer
If your app sits behind nginx, a cloud load balancer, Cloudflare, or a PaaS router, then req.socket.remoteAddress is the proxy, and by default req.ip is the proxy too. Every user shares one key. Your 60-per-minute limit is now 60 per minute for the entire internet.
Express derives req.ip from x-forwarded-for only when trust proxy is set. With a number it counts hops: req.socket.remoteAddress is the first, and the rest are looked for in the X-Forwarded-For header from right to left, stopping at the first untrusted address. Set the number of proxies between the user and your app:
app.set('trust proxy', 1)
Do not set it to true. That takes the leftmost value in x-forwarded-for, which is client-controlled, and any attacker who wants a fresh bucket sends a new header. express-rate-limit logs a validation error for exactly this, ERR_ERL_PERMISSIVE_TRUST_PROXY.
Verify the number with a temporary route:
app.get('/debug/ip', (req, res) =>
res.json({ ip: req.ip, xff: req.headers['x-forwarded-for'] }),
)
Hit it from your phone on cellular data and compare ip to your actual address. If it shows the proxy, increment the hop count until it matches, then delete the route.
Skip the setting entirely and the library tells you the other way round. It logs ERR_ERL_UNEXPECTED_X_FORWARDED_FOR when the header is set but trust proxy is false, which is the combination that applies your limit globally instead of per user.
3. Two instances, two counters
Scale to two replicas and your limit doubles. The built-in memory store, in the maintainers' own words, "does not share state when app has multiple processes or servers". Under pm2 start -i 4, four workers means four counters, so roughly 240 requests a minute get through your 60-per-minute rule. A deploy resets all of them to zero.
There is an @express-rate-limit/cluster-memory-store for the node:cluster case, but it needs to run code in the primary process, which pm2's cluster mode owns. If you are on pm2, Docker replicas, Fly machines, or anything serverless, you need an external store.
4. Share the counter: the express-rate-limit Redis store
npm install rate-limit-redis redis
import { createClient } from 'redis'
import { RedisStore } from 'rate-limit-redis'
const redis = createClient({ url: process.env.REDIS_URL })
redis.on('error', (err) => console.error('redis error', err))
await redis.connect()
const apiLimiter = rateLimit({
windowMs: 60_000,
limit: 60,
standardHeaders: 'draft-8',
legacyHeaders: false,
store: new RedisStore({
prefix: 'rl:api:',
sendCommand: (...args) => redis.sendCommand(args),
}),
})
Verify by starting two processes on different ports, or just by watching Redis directly. Run redis-cli --scan --pattern 'rl:api:*' after a few requests and you will see one key per client, with TTL on it counting down toward the end of the window.
That store is still a fixed window. Its Lua script increments the key and sets an expiry on it in one round trip, which is the rate limiter pattern in the Redis docs written as a script to close the race that page warns about, where a client runs INCR but never runs EXPIRE and leaks the key. Shared, correct, and still edge-bursty.
The sliding window, by hand
If the burst at the window boundary matters, keep a sorted set of request timestamps per key and count only the ones inside the trailing window. Four commands make the algorithm obvious:
const SLIDING = `
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then return {0, count} end
redis.call('ZADD', KEYS[1], now, ARGV[4])
redis.call('PEXPIRE', KEYS[1], window)
return {1, count + 1}
`
import { randomUUID } from 'node:crypto'
export function slidingWindow({ name, windowMs, limit, keyFor }) {
const seconds = Math.ceil(windowMs / 1000)
return async (req, res, next) => {
let allowed, count
try {
;[allowed, count] = await redis.eval(SLIDING, {
keys: [`sw:${name}:${keyFor(req)}`],
arguments: [String(Date.now()), String(windowMs), String(limit), randomUUID()],
})
} catch (err) {
console.warn('rate limiter degraded, failing open', err)
return next()
}
res.setHeader('RateLimit-Policy', `"${name}";q=${limit};w=${seconds}`)
if (!allowed) {
res.setHeader('Retry-After', seconds)
return res.status(429).json({ error: 'rate_limited' })
}
res.setHeader('RateLimit', `"${name}";r=${limit - count};t=${seconds}`)
next()
}
}
Cost: one sorted set member per request, expired by PEXPIRE, so memory is bounded by limit × active keys rather than by traffic.
The catch block is the decision people skip. When Redis is unreachable, you either fail open (serve the request, no limit enforced) or fail closed (429 everyone). For a public API, failing closed turns a Redis blip into a full outage, which is a worse day than an unlimited five minutes. Fail open on the general limiter, and if you have one endpoint where unlimited is genuinely dangerous, fail closed on that one alone.
5. Pick the key before you pick the number
Raw IP is the default and the worst key you can ship on an authenticated route.
Shared egress is the reason. Mobile carriers, universities, corporate NAT, VPN exit nodes, and requests originating from serverless platforms all put thousands of unrelated users behind a handful of addresses. On Hacker News, zenexer put it plainly: "If you rate limit too strictly based on IP address, you harm users who are stuck with CGNAT, especially in Asia and Africa."
The same key fails in the other direction against anyone deliberate. In the thread on AI crawlers overwhelming open-access repositories, jrochkind1: "Rate limiting by IP (or by CIDR subnet of various sizes) was not enough for me." The bots spread across more addresses and kept saturating the server anyway. Residential proxy pools are sold by the gigabyte, so rotating out of a per-IP bucket costs an attacker nothing.
So: key authenticated traffic on the identity you issued, and keep IP only as a coarse ceiling.
import { ipKeyGenerator } from 'express-rate-limit'
const byApiKey = rateLimit({
windowMs: 60_000,
limit: 600,
standardHeaders: 'draft-8',
legacyHeaders: false,
keyGenerator: (req) => req.apiKeyId ?? ipKeyGenerator(req.ip),
store: new RedisStore({ prefix: 'rl:key:', sendCommand: (...a) => redis.sendCommand(a) }),
})
Use ipKeyGenerator rather than req.ip for the fallback. A single IPv6 user is typically handed a /64 or larger, so keying on the full address lets them rotate through billions of buckets; the helper applies the ipv6Subnet option, which defaults to 56. When your custom keyGenerator looks like IPv6 users could walk around it, the library logs ERR_ERL_KEY_GEN_IPV6. Fix the generator rather than switching the check off.
Sensible starting shape, before you have traffic data:
| Route | Key | Window | Purpose |
|---|---|---|---|
/api/* (authed) | API key or user id | 1 min | per-tenant fairness |
/api/* (any) | IP /56 | 1 min | coarse ceiling only, set high |
/login | account identifier | 15 min | slow credential stuffing per account |
/signup, /checkout | IP /56 | 1 hour | crude flood ceiling |
Keying login attempts on the account rather than the IP is the one place the choice is not close: it is the difference between locking one account's attacker out and letting an attacker lock a whole office out of their own accounts.
6. Headers that let good clients back off
standardHeaders: 'draft-8' emits the format from draft-ietf-httpapi-ratelimit-headers, still a draft at revision 11: a RateLimit-Policy describing the quota and a single RateLimit header carrying remaining quota and seconds to reset, both as structured fields.
RateLimit-Policy: "api";q=60;w=60
RateLimit: "api";r=41;t=37
Retry-After: 37
Draft-6 sent the older separate RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers; draft-7 and draft-8 combine them. Set legacyHeaders: false unless you have clients parsing X-RateLimit-* today. Whichever draft you pick, Retry-After on the 429 is the one every HTTP client library already knows how to obey, so always send it.
Verify with curl -i localhost:3000/api/ping and read the headers back.
What to change for production
- Put the limiter after
app.set('trust proxy', n)and before your routers, but after your health check route, so an unhealthy-looking 429 never takes an instance out of rotation. - Set
limitfrom your own p99 request rate per key over a week, not from the README's 100. Log 429s with the key and route for a week with the limit deliberately high, then set the number where it clips the top 0.1%. - Give expensive endpoints their own limiter instance and their own
prefix. A password reset and a list endpoint do not belong in one bucket. - Point the store at the same Redis your app already uses, but a separate logical database or key prefix, so a
FLUSHDBduring a cache incident does not reset your limits at the same moment. - Skip limiting for internal callers with
skip, not by exempting an IP range in your proxy config where nobody will find it later.
Mistakes worth naming
Copying max: 100 out of the README into production. It is an example value. Ship it on an endpoint your own dashboard polls every second and you will page yourself.
Setting trust proxy to true to make the validation warning go away. That hands the key to the client.
Treating a strict limit on /signup or /checkout as fraud prevention. It slows the slowest attacker and irritates the corporate NAT. Anyone running a farm across residential proxies never sees the limit at all, and if they do, the 429 tells them what to stay under.
A rate limit is capacity. Abuse is a different question.
Everything above answers one question: how many requests may this key make in this window. It applies the same number to your best customer and to a script, and it answers with a 429 that says "try again in 37 seconds", which is exactly the instruction an attacker follows.
The question underneath the one you searched for is usually different: is this pattern of signups, cards, or logins the shape of abuse. Twenty signups a day from one device is nothing for capacity and is obviously a farm. Six declined cards in four minutes from six different numbers on one browser is a trivial request rate and a card testing run. A login from a device this account has never used is one request.
None of those trip a rate limiter tuned for load, and 429 is the wrong answer to all of them. It tells the attacker the counter exists and where the edge is, and a card tester simply slows to one card a minute and carries on. Velocity counters keyed to identity rather than to request volume are a separate mechanism, and how those counters are built is worth understanding before you try to tune a rate limiter into doing their job.
That second question is what I built Portreeve for: one call at signup, login, or checkout_attempt returns allow, review, or block with reason codes, reading velocity counters per IP, per device, and per card rather than per request. IP there is a soft signal that never links accounts on its own, for the same shared-egress reason it makes a bad rate limit key. It sits behind the rate limiter, not instead of it: the limiter protects your capacity, and the verdict decides what happens to the account.
The free tier screens 1,000 events a month with no card. Start there.