How to test rate limiter under burst

Fire more requests at once than the limit allows, and count the answers. A burst of 150 inside one window was cut to 100 by both limiters below. Split across the window reset, the same burst passed 200 through the fixed window in 118 ms and 106 through the token bucket.

Why check this

Run this after any change to a limiter: a new limit, a new store behind it, a new route brought under it. A sustained-rate test at the nominal limit passes on every algorithm.

The failure it catches is a limiter published as "100 requests per second" that delivers 200 to the handler in a tenth of a second. The handler is sized for the published figure, and the burst lands at the reset instant because client retry timers align on one clock.

Generator and server share eight cores here, and loopback carries no connection setup. Every count below is the limiter's decision, not a capacity figure.

Prerequisites

// limiter-server.mjs  -  one server, four limiter algorithms, the same budget of 100 per second
import { createServer } from 'node:http';
const LIMIT = 100, WINDOW = 1000, LOG_LIMIT = 50000;
const fixed = new Map(), bucket = new Map(), log = new Map();
const ok = (res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); };
const deny = (res, retry) => { res.writeHead(429, { 'retry-after': String(retry), 'content-type': 'application/json' }); res.end('{"error":"rate_limited"}'); };

function fixedWindow(key, res) {                     // counter that resets on the wall clock
  const w = Math.floor(Date.now() / WINDOW);
  const e = fixed.get(key);
  if (!e || e.w !== w) { fixed.set(key, { w, n: 1 }); return ok(res); }
  if (e.n < LIMIT) { e.n += 1; return ok(res); }
  return deny(res, Math.ceil(((w + 1) * WINDOW - Date.now()) / 1000));
}
function tokenBucket(key, res, wait) {               // refills continuously, 100 tokens per second
  const now = Date.now();
  const e = bucket.get(key) ?? { t: LIMIT, at: now };
  e.t = Math.min(LIMIT, e.t + ((now - e.at) * LIMIT) / WINDOW);
  e.at = now;
  bucket.set(key, e);
  if (e.t >= 1) { e.t -= 1; return ok(res); }
  const ms = Math.ceil(((1 - e.t) * WINDOW) / LIMIT);
  if (wait) return void setTimeout(() => tokenBucket(key, res, true), ms);
  return deny(res, Math.ceil(ms / 1000));
}
function slidingLog(key, res) {                      // one timestamp per request, rescanned every time
  const now = Date.now();
  const a = (log.get(key) ?? []).filter((t) => t > now - WINDOW);
  a.push(now);
  log.set(key, a);
  return a.length <= LOG_LIMIT ? ok(res) : deny(res, 1);
}

createServer((req, res) => {
  const ip = req.socket.remoteAddress;
  const key = req.headers['x-api-key'] ?? 'anon';
  const p = req.url.split('?')[0];
  if (p === '/open') return ok(res);
  if (p === '/fixed') return fixedWindow(ip, res);
  if (p === '/keyed') return fixedWindow(`k:${key}`, res);
  if (p === '/bucket') return tokenBucket(ip, res, false);
  if (p === '/queue') return tokenBucket(`q:${ip}`, res, true);
  if (p === '/sliding') return slidingLog(ip, res);
  res.writeHead(404).end();
}).listen(9760, '127.0.0.1', () => console.log('limiter on 9760'));
// burst.mjs <url> <count>   -  waits for the middle of a wall-clock second, then fires count at once
const [url, count] = [process.argv[2], +process.argv[3]];
const at = Math.ceil((Date.now() + 300) / 1000) * 1000 + 400;
while (Date.now() < at) await new Promise((r) => setTimeout(r, 1));
const t0 = performance.now();
const rs = await Promise.all(Array.from({ length: count }, () => fetch(url)));
const ms = performance.now() - t0;
const tally = new Map();
for (const r of rs) tally.set(r.status, (tally.get(r.status) ?? 0) + 1);
console.log(`${count} requests in ${ms.toFixed(0)} ms, starting 400 ms into the window`);
for (const [s, n] of [...tally].sort()) console.log(`  ${s}  ${n}`);
const r429 = rs.find((r) => r.status === 429);
if (r429) console.log(`  retry-after: ${r429.headers.get('retry-after')}`);
// straddle.mjs <url> <count> <msBefore> <msAfter>
// Fires one burst that finishes before the next wall-clock second, and one that starts after it.
const [url, count, before, after] = process.argv.slice(2, 6).map((v, i) => (i ? +v : v));
const boundary = Math.ceil((Date.now() + 400) / 1000) * 1000;
async function fire(at) {
  while (Date.now() < at) await new Promise((r) => setTimeout(r, 1));
  const rs = await Promise.all(Array.from({ length: count }, () => fetch(url)));
  const done = Date.now() - boundary;
  const t = new Map();
  for (const r of rs) t.set(r.status, (t.get(r.status) ?? 0) + 1);
  return { t, start: at - boundary, done };
}
const pad = (n) => (n >= 0 ? `+${n}` : `${n}`);
const [a, b] = await Promise.all([fire(boundary - before), fire(boundary + after)]);
for (const r of [a, b]) {
  console.log(`burst ${pad(r.start)} ms to ${pad(r.done)} ms around the reset   ${[...r.t].sort().map(([s, n]) => `${s}:${n}`).join('  ')}`);
}
const passed = [a, b].reduce((s, r) => s + (r.t.get(200) ?? 0), 0);
console.log(`allowed across ${a.done - a.start + (b.done - b.start)} ms of traffic: ${passed}   nominal limit: 100 per second`);
// rejection.mjs <url>  -  fires 150 at once and prints the first rejected answer in full
const url = process.argv[2];
const rs = await Promise.all(Array.from({ length: 150 }, () => fetch(url)));
const r = rs.find((x) => x.status === 429) ?? rs[0];
console.log(`HTTP ${r.status} ${r.statusText}`);
for (const [k, v] of r.headers) console.log(`${k}: ${v}`);
console.log(await r.text());
// keys.mjs <url> <count>  -  two bursts with two different API keys, inside one window
const [url, count] = [process.argv[2], +process.argv[3]];
const at = Math.ceil((Date.now() + 300) / 1000) * 1000 + 50;
while (Date.now() < at) await new Promise((r) => setTimeout(r, 1));
for (const key of ['alpha', 'beta']) {
  const rs = await Promise.all(Array.from({ length: count }, () => fetch(url, { headers: { 'x-api-key': key } })));
  const t = new Map();
  for (const r of rs) t.set(r.status, (t.get(r.status) ?? 0) + 1);
  console.log(`key ${key.padEnd(6)} at +${Date.now() - at + 50} ms in the window   ${[...t].sort().map(([s, n]) => `${s}:${n}`).join('  ')}`);
}

Steps

  1. Step 1.

    Send 150 at once from the middle of one window.

    for path in fixed bucket; do echo "== /$path"; node burst.mjs "http://127.0.0.1:9760/$path" 150; sleep 2; done
    
    == /fixed
    150 requests in 116 ms, starting 400 ms into the window
    200  100
    429  50
    retry-after: 1
    == /bucket
    150 requests in 98 ms, starting 400 ms into the window
    200  100
    429  50
    retry-after: 1

    Both limiters allowed exactly 100 and rejected 50. This run cannot tell the algorithms apart, so a report containing only it says nothing about which one is deployed.

  2. Step 2.

    Read one rejection in full, headers and body.

    node rejection.mjs http://127.0.0.1:9760/bucket
    
    HTTP 429 Too Many Requests
    connection: keep-alive
    content-type: application/json
    date: Sat, 12 Sep 2026 11:07:55 GMT
    keep-alive: timeout=5
    retry-after: 1
    transfer-encoding: chunked
    {"error":"rate_limited"}

    A status, a retry-after in seconds and a typed body. Record all three: a client written against 429 alone retries at once, and one written against the body breaks when the status changes.

  3. Step 3.

    Put the same burst across the window reset, 100 either side.

    for path in fixed bucket; do echo "== /$path"; node straddle.mjs "http://127.0.0.1:9760/$path" 100 150 10; sleep 2; done
    
    == /fixed
    burst -150 ms to -59 ms around the reset   200:100
    burst +10 ms to +37 ms around the reset   200:100
    allowed across 118 ms of traffic: 200   nominal limit: 100 per second
    == /bucket
    burst -150 ms to -24 ms around the reset   200:100
    burst +10 ms to +29 ms around the reset   200:6  429:94
    allowed across 145 ms of traffic: 106   nominal limit: 100 per second

    The fixed window passed 200 requests inside 118 ms while advertising 100 per second. Its counter reset between the bursts, and a counter keeps no memory across a reset. The token bucket passed 106: the second burst found an empty bucket and drew 6 refilled tokens.

  4. Step 4.

    Compare a limiter that rejects with one that delays, at the same offered load.

    for path in bucket queue; do echo "== /$path"; npx autocannon@8 -c 20 -d 10 "http://127.0.0.1:9760/$path" 2>&1 | grep -E "^│ (Latency|Req/Sec)|requests in|non 2xx"; done
    
    == /bucket
    │ Latency │ 0 ms │ 0 ms │ 0 ms  │ 0 ms │ 0.01 ms │ 0.12 ms │ 13 ms │
    │ Req/Sec   │ 34 399  │ 34 399  │ 39 359  │ 40 319  │ 38 881,46 │ 1 534,45 │ 34 399  │
    1199 2xx responses, 426518 non 2xx responses
    428k requests in 11.01s, 97.9 MB read
    == /queue
    │ Latency │ 0 ms │ 226 ms │ 385 ms │ 513 ms │ 179.34 ms │ 124.47 ms │ 636 ms │
    │ Req/Sec   │ 100     │ 100     │ 101     │ 199     │ 110,4   │ 29,54   │ 100     │
    1k requests in 10.08s, 203 kB read

    Same algorithm, same budget, opposite reports. The rejecting route answered 428k requests in 11 seconds and served 1199, at a median of 0 ms. The delaying route answered 1k, all of them successfully, at a median of 226 ms. An error-rate dashboard calls the second one healthy.

  5. Step 5.

    Send two bursts from one address under two API keys, inside one window.

    for path in fixed keyed; do echo "== /$path"; node keys.mjs "http://127.0.0.1:9760/$path" 100; sleep 2; done
    
    == /fixed
    key alpha  at +137 ms in the window   200:100
    key beta   at +159 ms in the window   429:100
    == /keyed
    key alpha  at +130 ms in the window   200:100
    key beta   at +174 ms in the window   200:100

    On /fixed the second key inherited an exhausted budget, because the counter is keyed on the client address. On /keyed it got a fresh 100. Two credentials from one address inside one window separates them from outside. No response header does.

  6. Step 6.

    Measure what the limiter costs the route.

    for path in open sliding bucket; do echo "== /$path"; npx autocannon@8 -c 20 -d 10 "http://127.0.0.1:9760/$path" 2>&1 | grep -E "^│ (Latency|Req/Sec)|requests in|non 2xx"; done
    
    == /open
    │ Latency │ 0 ms │ 0 ms │ 0 ms  │ 1 ms │ 0.02 ms │ 0.16 ms │ 15 ms │
    │ Req/Sec   │ 32 799  │ 32 799  │ 41 183  │ 42 591  │ 40 138,19 │ 2 815,47 │ 32 797  │
    442k requests in 11.01s, 81.2 MB read
    == /sliding
    │ Latency │ 0 ms │ 1 ms │ 2 ms  │ 2 ms │ 1.15 ms │ 0.49 ms │ 14 ms │
    │ Req/Sec   │ 10 167  │ 10 167  │ 11 383 │ 14 447  │ 11 403,28 │ 1 081,84 │ 10 167  │
    125k requests in 11.01s, 23.1 MB read
    == /bucket
    │ Latency │ 0 ms │ 0 ms │ 0 ms  │ 1 ms │ 0.02 ms │ 0.15 ms │ 14 ms │
    │ Req/Sec   │ 33 663 │ 33 663 │ 39 455  │ 40 959  │ 38 768  │ 2 019,95 │ 33 657 │
    1199 2xx responses, 425234 non 2xx responses
    426k requests in 11.01s, 97.6 MB read

    /sliding rejected nothing: its log limit is 50000 per second and the run never reached it. It still served 125k where the unprotected route served 442k, because every request filters an array holding the last second of traffic. The token bucket cost 4% of that throughput.

  7. Step 7.

    Stop the target and confirm the port is clear.

    netstat -ano | grep "127.0.0.1:9760 " | grep LISTENING; powershell -Command "Stop-Process -Id 6416 -Force"; netstat -ano | grep ":9760 " | grep -c LISTENING
    
      TCP    127.0.0.1:9760         0.0.0.0:0              LISTENING       6416
    0

    Read the process id from the last column and stop that id.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | A burst inside one window is cut to the limit | The limiter is engaged on this route | Keep going. This run cannot name the algorithm. | | A burst across the reset passes about twice the limit | A fixed window counter | Size the handler for twice the published number, or move to a bucket. | | The same burst passes about the limit wherever it lands | A token bucket or a sliding window | Record the burst capacity as well as the rate. | | 429 with retry-after and a typed body | The client has something to act on | Test that the client waits for it. | | Latency climbs, non-2xx stays at zero | The limiter delays instead of rejecting | Find the queue bound and compare it with the caller timeout. | | Fewer responses than requests sent | Nothing answered, the excess was dropped | Read the client timeout, not the status codes. | | A second credential from one address gets a fresh budget | The limit is keyed on the credential | Test the quota per key, not per address. | | The limited route is far slower than an unprotected one | The limiter is the bottleneck | Look at what it stores per client per request. |

Common mistakes

Sign: The limiter passes every test at a sustained rate, then lets a flood through in production.Cause: A sustained rate never crosses the reset instant with a full burst either side of it. Step 1 and step 3 use the same 200 requests against the same fixed window: spread inside one window it allowed 100, split across the reset it allowed 200 in 118 ms. Every fixed window has this, and clients whose retry timers align on the clock find it without trying.
Sign: A load run reports zero errors, so the limiter is recorded as not engaged.Cause: A limiter that delays instead of rejecting produces no error at all. The `/queue` run answered 1k requests with no non-2xx line and a median latency of 226 ms, against 428k requests and 426518 non-2xx from the same algorithm set to reject. Read throughput and latency together with the error count, never the error count alone.
Sign: A sliding-window limiter is chosen for accuracy, and the route gets slower than the thing it protects.Cause: A timestamp log costs work per request in proportion to the traffic it has already seen. Rejecting nothing, `/sliding` served 125k requests where the unprotected route served 442k and the token bucket 426k. The accurate algorithm spent 72% of the route's throughput on bookkeeping.

What to check next

FAQ

How do I tell whether a limit is per IP or per API key?

Send two bursts from one address under two credentials inside one window, as step 5 does. A fresh budget for the second means the key is the bucket. An immediate 429 means the address is, or the limit is global. No header says.

What burst size should I send?

Start at 1.5 times the published limit. Step 1 sent 150 against a limit of 100 and got a clean 100 and 50 split. Raise it when the rejection path needs measuring.

Why does a fixed window let twice the limit through?

Its counter resets on a clock instant and keeps no record of what came before, so the end of one window and the start of the next are counted separately. Step 3 measured 200 allowed in 118 ms against a nominal 100 per second.

Should a limiter return 429 or hold the request?

Both were measured in step 4. Rejecting held the median at 0 ms and gave the client a decision. Holding kept the error count at zero and moved the median to 226 ms, which becomes a timeout when the caller's deadline is shorter.

Verified

Verified by Maks Vernyautocannon 8.0.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate16 minpublished updated Maks Verny