Skip to content

Throttling

An attendant at the top of a water slide. They don't turn children away, they make each one wait a bit longer than the last.

Throttling slows requests down after a certain number have gone through, instead of refusing them at a hard cap. The client still gets an answer, later.

A delay instead of a refusal

express-slow-down is middleware, same shape as the limiter: it counts requests in a window and starts delaying past a threshold.

js
import { slowDown } from 'express-slow-down'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 2,
  delayMs: () => 5000,
})

app.get('/api/data', throttle, handler)

Three settings: the window, how many get through untouched, and how long to delay everything after that.

Fire five requests at once and the first two return immediately. The other three each wait five seconds, then succeed.

RequestArrivedDelayResponded
10snone0s
20snone0s
30s5s5s
40s5s5s
50s5s5s

Nothing was refused. Every request succeeded, and three of them took five seconds longer than they wanted to.

JunoA delay instead of a refusal The client never learns it was throttled. There's no error and no status code to check, only a response that took longer.

That's the appeal and the catch. Nothing breaks, and nothing tells the client to ease off either.

JunoA delay instead of a refusal A delayed request is a held-open connection, so throttling costs you resources where refusing frees them. A few seconds each across a burst is fine; a long delay applied widely is a way to exhaust your own connection pool.

Which is the argument for pairing it with a hard limit rather than using it alone, and for keeping delays in the low seconds.

JunoA delay instead of a refusal Verified on express-slow-down 3.1.1: delayMs takes a function, where older versions accepted a bare number. Passing a number now is a configuration error rather than a silent fallback, which is the kind of upgrade break worth pinning versions over.

The deeper reason to prefer throttling on well-behaved clients is that it applies backpressure without needing the client to cooperate. A 429 only helps if the caller implements backoff; a delay slows them down whether they wrote that code or not.

Against a deliberate attacker it's weaker, since they can open more connections and are happy to wait. Throttle to shape ordinary traffic, refuse to stop abuse.

How fast the delay grows

delayMs is a function of hits, the number of requests counted in the window, so the delay can grow with pressure.

js
delayMs: () => 5000                              // fixed: always 5s
delayMs: (hits) => (hits - 2) * 1000             // incremental: 1s, 2s, 3s
delayMs: (hits) => hits * hits * 1000            // exponential: 9s, 16s, 25s

With delayAfter: 2, the three shapes behave very differently by the fifth request:

HitFixedIncrementalExponential
35s1s9s
45s2s16s
55s3s25s

Incremental subtracts delayAfter so the first delayed request waits one second rather than three. Exponential squares the hit count, so it climbs steeply enough that sustained pressure becomes painful fast.

JunoHow fast the delay grows Look at the fifth request across the three columns: three seconds, or twenty five. Same middleware, same threshold.

The curve you pick matters more than the threshold does, and that only becomes clear with the numbers side by side.

JunoHow fast the delay grows Incremental is the sensible default. It's gentle on a client that briefly overshot and firm on one that keeps going, and the delays stay in a range a caller's timeout will tolerate.

Exponential needs a ceiling. At hit 20 the squared version asks for 400 seconds, which is well past any client timeout, so the request is abandoned while your server holds the connection for it. maxDelayMs is what caps that.

JunoHow fast the delay grows Verified on 3.1.1, and worth knowing before you write a test: concurrent requests do not finish in arrival order. Five fired at once with an incremental delay returned request 4 at one second and request 3 at two, because hit numbers are assigned in the order the middleware counts them, not the order you sent them.

So a delayed request's position is decided by the hit number it drew, and any test asserting on arrival order will be flaky for reasons that have nothing to do with your code.

Worth pairing throttling with the RateLimit headers from the limiter beside it, too. A delay alone gives a client no signal it can act on, so a well-written caller has nothing to back off from.

What the window counts

express-slow-down uses a sliding window. Each request looks back windowMs and counts the hits inside it, and if nothing has arrived for longer than that, the count starts fresh.

The subtlety is what counts as being in the window: requests are counted by when they arrived, not when they were processed.

With windowMs: 10000 and delayAfter: 2, five requests arriving at once and five more at 30 seconds:

  • The first five all arrive at 0s. Two pass, three are delayed and respond later.
  • The second burst arrives at 30s. Looking back ten seconds covers 20s to 30s, and nothing arrived in that stretch, even though delayed responses from the first burst were still going out.
  • So the count starts fresh, two pass, and three are delayed again.

Delayed responses leaving the server do not extend the window. Only new arrivals do.

JunoWhat the window counts The water slide attendant thinks back ten seconds and asks who turned up in that time, not who is still standing around waiting for their timer.

Those waiting children were counted when they arrived. Counting them twice would be charging them for the same visit.

JunoWhat the window counts This is what makes bursty clients able to keep going. Spend the burst, wait out the window, spend another. The delays never compound across bursts, because the window has forgotten the last one.

If that's not what you want, the window needs to be longer than the gap between bursts, or a hard limit needs to sit behind the throttle.

JunoWhat the window counts Arrival-time counting is the right choice and it does create an odd interaction with a limiter behind it. A request delayed by sixteen seconds is counted by the throttle at arrival and by the limiter at processing, so the two middlewares are measuring the same request at different moments.

That produces the reordering in the next section, where a later request overtakes an earlier one because the earlier one is still sitting on a timer.

Worth remembering when reading logs: throttle counts and limiter counts will disagree, and neither is wrong.

Stacking a throttle and a limiter

The two combine, and the order matters because Express runs middleware left to right:

js
app.get('/api/data', throttle, limiter, handler)

Throttle first means requests get slowed progressively, and only past a hard ceiling does the limiter refuse them. Reverse the order and the limiter cuts clients off before the throttle ever gets to be gentle with them.

A worked brief from the course. The system degrades past three requests in ten seconds, so delay the fourth by 1s, the fifth by 4s, the sixth by 9s. And no more than seven requests in thirty seconds, total.

js
import { slowDown } from 'express-slow-down'
import { rateLimit } from 'express-rate-limit'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 3,
  delayMs: (hits) => (hits - 3) * (hits - 3) * 1000,
})

const limiter = rateLimit({
  limit: 7,
  windowMs: 30000,
  message: 'Too many requests',
  standardHeaders: 'draft-6',
  legacyHeaders: false,
})

The delay formula reads backwards from the required pattern. Hit 4 must give 1, hit 5 must give 4, hit 6 must give 9, which is 1², 2², 3². Subtracting delayAfter turns the hit number into the base, then squaring it produces the curve.

Run seven rapid requests and something unexpected happens. Request 7, delayed sixteen seconds, arrives at the limiter after request 8, which drew a shorter timer. Request 8 becomes the seventh success and request 7 is refused.

Throttled requests are not a queue. Each one holds its own timer and arrives when it arrives.

JunoStacking a throttle and a limiter The reordering looks like a bug and is not. Each delayed request got its own timer when it arrived, so a request with a short timer finishes before one with a long timer, whatever order they came in.

A leaky bucket would have kept them in line. A throttle hands out timers and lets them run.

JunoStacking a throttle and a limiter Which means throttle plus limiter can refuse a request that a limiter alone would have accepted. Request 7 was inside the limit when it arrived and outside it by the time its delay expired.

Worth knowing before you debug it as a limiter fault. The limiter counted correctly; the throttle changed when the request showed up to be counted.

JunoStacking a throttle and a limiter Neither middleware here identifies the client, so all this traffic shares one budget. Adding keyGenerator to both is what turns it into per-client shaping, where one heavy user is slowed and everyone else is served immediately.

Without that, a single client's burst throttles everybody, which converts a protective control into a self-inflicted denial of service.

The stack that holds up in production is three layers: identify the client, throttle to shape their traffic, and refuse past a hard ceiling. Each answers a different question, and the results get complicated enough that a chart of arrival time, delay and outcome is worth drawing before you trust your configuration.

Try it

An endpoint is configured with windowMs: 10000, delayAfter: 2, delayMs: (hits) => hits * hits * 1000, and no rate limiter.

  1. Four requests arrive at once. When does each respond?
  2. A client sends four requests every 15 seconds, forever. Do the delays get worse over time?
  3. What happens if a fifth request arrives and its delay exceeds the client's timeout?
  4. How would you change the configuration so a single client can't slow down everyone else?
Compare your answers
#Answer
1Requests 1 and 2 respond immediately. Request 3 is hit 3, so 3² = 9 seconds. Request 4 is hit 4, so 4² = 16 seconds
2No. The window is 10 seconds and the gap is 15, so each burst looks back at an empty window and starts counting from scratch. The same two-free-then-9s-then-16s pattern repeats forever
3The client gives up and your server keeps holding the connection until the delay expires. The work is done and nobody receives it, which is why exponential delays need maxDelayMs
4Add a keyGenerator so the throttle counts per client rather than globally. Without one, every request shares a single budget and one heavy client delays everybody

Question 2 is the one that surprises people. An exponential delay sounds like it punishes sustained abuse, and a client pacing their bursts to fall outside the window never feels it climb at all. The curve only bites inside a single window.

Where this goes next

Five algorithms and two controls, all answering one question: how much is this client allowed to ask for, and what happens when they ask for more.

The reasoning transfers past your own servers. Every external API you call has limits of its own, so understanding these improves your client code too: how it backs off, and how it handles a 429 instead of hammering a service that is already struggling.

That closes the handbook. Five sections, from thinking like an attacker through input safety, identity and abuse prevention, each one built on a working example you can break and then fix.