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
- Node 22 or later, and
npx autocannon@8, which fetches autocannon on first use. - A free port. Port 9760 is used below.
netstat -ano | grep ":9760 " | grep -c LISTENINGprints 0 when free. - The target, saved as
limiter-server.mjsand started withnode limiter-server.mjs &. One budget of 100 per second, four algorithms.
// 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, which fires from a known position inside the window.
// 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, which puts half the burst either side of the reset.
// 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.mjsandkeys.mjs: the rejected client's answer, and the budget two credentials share.
// 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
- 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: 1Both 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.
- Step 2.
Read one rejection in full, headers and body.
node rejection.mjs http://127.0.0.1:9760/bucketHTTP 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-afterin seconds and a typed body. Record all three: a client written against429alone retries at once, and one written against the body breaks when the status changes. - 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 secondThe 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.
- 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 readSame 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.
- 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:100On
/fixedthe second key inherited an exhausted budget, because the counter is keyed on the client address. On/keyedit got a fresh 100. Two credentials from one address inside one window separates them from outside. No response header does. - 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/slidingrejected 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. - 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 LISTENINGTCP 127.0.0.1:9760 0.0.0.0:0 LISTENING 6416 0Read 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
What to check next
- How to test API rate limiting: the single-sequence version, on a service you do not own.
- How to check rate limit headers: what the limiter says before the burst.
- How to test retry-after header: whether the client obeys step 2's number.
- How to test graceful degradation: the same decision one layer down.
- How to check requests per second an api can handle: where the limit number comes from.
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.
Related on this site
intermediate16 minpublished updated Maks Verny