Building a rate limiter
One endpoint, and a script that hits it fifteen times in a row.
app.get('/api/data', (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})Fifteen requests, fifteen 200s. Nothing is counting.
Putting a limiter in front
express-rate-limit is middleware, so it sits between the path and the handler and decides whether the handler runs at all.
import { rateLimit } from 'express-rate-limit'
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
})
app.get('/api/data', limiter, (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})Three settings: how many, over what window in milliseconds, and what to say when refusing. Run the fifteen requests again and the first five come back 200, then the rest are 429 Too Many Requests.
This is not quite a pure fixed window
In the algorithm, windows tick along on a schedule whether requests arrive or not. This package documents windowMs as a window that starts at the client's first request, then resets their count when it elapses.
Call it a lazily started window. The boundary burst still exists, and the boundary is now wherever each client happened to begin rather than a shared point on the clock.
The middleware also attaches its state to the request:
req.rateLimit // { limit, remaining, reset, used }Useful for debugging, and the wrong place for a client to read it from.
You don't change the handler at all. You change what has to happen before it.
Telling the client where they stand
Rate limit information belongs in headers. JSON is for what the application returns; headers are for information about the exchange itself, alongside content type and status codes.
The practical argument is stronger than the tidiness one. Without headers, a client discovers the limit by hitting it. With them, every response says how much budget is left, so a well-written client eases off before being refused.
That matters for paid APIs, where a wasted request costs money, and it makes debugging possible without parsing bodies.
There are two formats, and both turn up in the wild:
| Era | Headers |
|---|---|
| Legacy | X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset |
| Standard, from around 2021 | RateLimit-Limit, RateLimit-Policy, RateLimit-Remaining, RateLimit-Reset |
The package still defaults to the legacy set, so ask for the modern one explicitly:
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
standardHeaders: 'draft-6',
legacyHeaders: false,
})Now every response carries the client's position:
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60With that in place, req.rateLimit comes out of the JSON body. The headers do the job properly.
A client can watch Remaining fall and ease off before it reaches zero, instead of finding the wall by walking into it.
Watching it reset
Fifteen requests fired within a second all land in one window, so you see five successes and ten refusals and never see a reset.
Shrink the window and slow the client down, and the cycle becomes visible. A limit of 3 per 10 seconds, with 2 seconds between requests:
| Requests | Outcome |
|---|---|
| 1 to 3 | 200, with Remaining counting 2, 1, 0 |
| 4 and 5 | 429, Reset counting down |
| 6 to 8 | 200 again, the window has reset |
| 9 and 10 | 429 |
That's the algorithm's whole behaviour in one run: a burst allowed, a wall, a reset, another burst. Which is also the boundary problem made visible, since three requests immediately before a reset and three immediately after put six through in a moment.
Two seconds between requests and you can watch the reset happen, which is the part that actually explains the behaviour.
Try it
An API is limited to 100 requests per minute, and clients keep reporting that requests fail without warning.
const limiter = rateLimit({ limit: 100, windowMs: 60000 })
app.use(limiter)
app.get('/api/data', (req, res) => {
res.json({ data, rateLimit: req.rateLimit })
})Find three problems.
Compare your answers
1. No standard headers. With standardHeaders unset, the package emits only the legacy X-RateLimit-* set, so a client following the modern standard sees nothing and cannot know it's approaching the limit. Set standardHeaders: 'draft-6' and legacyHeaders: false.
2. Rate limit state in the JSON body. req.rateLimit in the response works for this endpoint and nowhere else. A 429 returns the error body, not this one, so the information vanishes exactly when it's needed. Headers arrive on every response including refusals.
3. app.use(limiter) applies one budget to everything. A login attempt and a data read share the same 100, so ordinary browsing exhausts the budget meant to protect the login endpoint.
Attach limiters per route, with tighter numbers on the sensitive ones.
There's a fourth worth noticing if the API runs on more than one instance: the default store is in-process, so each instance counts separately and the real limit is 100 times the instance count.
Where this goes next
The limiter works, and every request it counts is attributed to whoever the package decides made it. So far that's been the client's IP address, chosen by default rather than by you.
Identifying clients makes that decision explicit, because what a limit counts against changes who it protects and who it punishes.

