Skip to content

Building a rate limiter

One endpoint, and a script that hits it fifteen times in a row.

js
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.

js
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:

js
req.rateLimit  // { limit, remaining, reset, used }

Useful for debugging, and the wrong place for a client to read it from.

JunoPutting a limiter in front The middleware position is the whole design, and it's the same arrangement as the validation chapter: something sits in front, and the handler only runs if it passed.

You don't change the handler at all. You change what has to happen before it.

JunoPutting a limiter in front Attaching the limiter per route, not to the whole app, is worth doing deliberately. A login endpoint and a read-only data endpoint want very different budgets, and a single global limiter gives them the same one.

The usual shape is a generous app-wide limiter plus tight named ones on the few endpoints that need them.

JunoPutting a limiter in front The default store is in-process memory, which means each instance counts separately and your effective limit multiplies by however many you run. Two instances behind a load balancer turn a limit of 5 into 10.

Fixing it needs a shared store, and the naive Redis version has a race. A read-decide-write counter, tested with 400 concurrent requests against a limit of 100, allowed all 400 through.

The answer is making the check atomic, with an atomic increment or a Lua script, since Redis runs a script to completion.

Also worth knowing passOnStoreError, which decides what happens when the store is unreachable. It defaults to false, so traffic is blocked and a Redis outage becomes an outage for you.

Whether that or open-by-default is right depends entirely on what the endpoint does.

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:

EraHeaders
LegacyX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Standard, from around 2021RateLimit-Limit, RateLimit-Policy, RateLimit-Remaining, RateLimit-Reset

The package still defaults to the legacy set, so ask for the modern one explicitly:

js
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:

http
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60

With that in place, req.rateLimit comes out of the JSON body. The headers do the job properly.

JunoTelling the client where they stand The headers arrive on every response, not only the refusals. That's what makes them useful.

A client can watch Remaining fall and ease off before it reaches zero, instead of finding the wall by walking into it.

JunoTelling the client where they stand Watch the units on Reset, because the two formats disagree. Verified on version 8.7.0: the legacy X-RateLimit-Reset is a Unix timestamp, while draft-6's RateLimit-Reset is seconds remaining.

Reading one as the other gives you a wait of fifty-odd years or a retry that fires immediately, and both look like a client bug rather than a units bug.

JunoTelling the client where they stand The standard has moved on since draft-6 and the package tracks it. Drafts 7 and 8 replace the separate fields with one combined header, and version 8 accepts 'draft-6', 'draft-7' or 'draft-8' where older versions took a boolean. Passing true still works and means draft-6.

Draft-8 looks nothing like the others, verified on 8.7.0:

RateLimit: "3-in-10sec"; r=2; t=10

Named policy, remaining, and time to reset, in one structured field. Worth knowing before you write a client that parses these, and worth pinning the draft explicitly so a future default cannot change it under you.

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:

RequestsOutcome
1 to 3200, with Remaining counting 2, 1, 0
4 and 5429, Reset counting down
6 to 8200 again, the window has reset
9 and 10429

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.

JunoWatching it reset Slowing the test client down is the trick worth keeping. Fired flat out, everything happens inside one window and the limiter looks like a simple on-off switch.

Two seconds between requests and you can watch the reset happen, which is the part that actually explains the behaviour.

JunoWatching it reset Test with the real client, not a browser refresh. A browser sends its own extra requests for icons and assets, each one consuming budget you did not mean to spend, which makes the numbers confusing before you have understood them.

A small script firing a known number of requests at a known interval is the tool that makes limiter behaviour legible.

JunoWatching it reset Tight limits like 3 per 10 seconds are for seeing behaviour, never for production. Real numbers come from measuring what legitimate clients do, then leaving headroom above the busiest of them.

Ship a limiter in monitoring mode first if you can: count and log what would have been refused, without refusing it.

A week of that tells you whether your number is protective or an outage you have scheduled for yourself. Much cheaper than finding out through a support queue.

Try it

An API is limited to 100 requests per minute, and clients keep reporting that requests fail without warning.

js
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.