Load Testing Rate Limits with k6
A load test aimed at a rate limiter answers one question: how many requests per second does one identity actually get in this deployment? Everything else — total throughput, latency percentiles, error rates — belongs to a different test. Getting the scenario right is mostly about what you hold constant, and it puts the measurement discipline from rate limit testing and validation into a runnable script.
The problem in concrete numbers
The usual first attempt: 500 virtual users, each with its own API key, ramping for five minutes. It reports 12,000 requests per second and a 0.3% error rate, and it proves nothing about the limiter, because 500 keys with a 100 rps limit each should produce exactly that. The limiter was never engaged.
The test that matters uses one key and offers more load than the limit allows, then measures how much was accepted. With a configured 100 rps and three application pods, the accepted rate comes back at 100 (shared counter, correct), 300 (per-pod counters, broken), or something between (a cache-based counter losing updates under concurrency).
Why arrival rate, not virtual users
With constant-vus, each user waits for a response before sending again, so a slow or rejected request reduces offered load — the classic coordinated-omission problem. Under a rate limiter this is fatal: as rejections start, virtual users cycle faster (429s are quick), offered load rises, and the numbers become uninterpretable. constant-arrival-rate decouples the two: k6 starts a new iteration on a schedule regardless of what earlier ones are doing, so offered load is exactly what you configured.
| Executor | Controls | Behaviour under 429 | Use for limiter tests |
|---|---|---|---|
constant-vus |
Concurrency | Offered load drifts upward | No |
ramping-vus |
Concurrency over time | Same drift, harder to read | No |
constant-arrival-rate |
Requests per second | Offered load fixed | Yes |
ramping-arrival-rate |
Requests per second over time | Fixed at each stage | Yes — for finding the knee |
Step-by-step: a limiter test that means something
// ratelimit.test.js — k6 script: measure the accepted plateau for ONE key.
import http from "k6/http";
import { check } from "k6";
import { Counter, Rate, Trend } from "k6/metrics";
const accepted = new Counter("rl_accepted");
const rejected = new Counter("rl_rejected");
const retryAfter = new Trend("rl_retry_after_seconds");
const contract = new Rate("rl_contract_ok");
const LIMIT_RPS = Number(__ENV.LIMIT_RPS || 100);
const DURATION_S = 60;
export const options = {
scenarios: {
// Offer 3x the limit at a fixed arrival rate: the limiter must clip it.
steady: {
executor: "constant-arrival-rate",
rate: LIMIT_RPS * 3, timeUnit: "1s", duration: `${DURATION_S}s`,
preAllocatedVUs: 200, maxVUs: 800,
exec: "hammerOneKey",
},
// Separate scenario: burst from idle, then wait — exercises the burst path.
bursty: {
executor: "per-vu-iterations",
vus: 1, iterations: 5, maxDuration: "2m",
startTime: `${DURATION_S + 5}s`,
exec: "burstFromIdle",
},
},
thresholds: {
// The core assertion: accepted requests land within 5% of limit x duration.
rl_accepted: [
`count>${Math.floor(LIMIT_RPS * DURATION_S * 0.95)}`,
`count<${Math.ceil(LIMIT_RPS * DURATION_S * 1.05)}`,
],
rl_contract_ok: ["rate>0.99"], // headers present and sane on every response
rl_retry_after_seconds: ["min>0"], // never advertise a zero-second wait
},
};
const URL = __ENV.TARGET || "https://staging.example.com/v1/search";
const KEY = __ENV.TEST_KEY; // ONE key: this is the whole point
function record(res) {
if (res.status === 429) {
rejected.add(1);
const ra = Number(res.headers["Retry-After"] || 0);
retryAfter.add(ra);
contract.add(ra >= 1);
check(res, { "429 has Retry-After": () => ra >= 1 });
} else if (res.status === 200) {
accepted.add(1);
const rem = res.headers["X-Ratelimit-Remaining"];
contract.add(rem !== undefined);
check(res, { "200 has remaining header": () => rem !== undefined });
} else {
contract.add(false); // 5xx during a limiter test is a real failure
}
}
export function hammerOneKey() {
record(http.get(URL, { headers: { "X-API-Key": KEY }, tags: { phase: "steady" } }));
}
export function burstFromIdle() {
// Fire the documented burst all at once, then idle long enough to refill.
const reqs = Array.from({ length: Number(__ENV.BURST || 20) },
() => ["GET", URL, null, { headers: { "X-API-Key": KEY }, tags: { phase: "burst" } }]);
http.batch(reqs).forEach(record);
sleepUntilRefilled();
}
function sleepUntilRefilled() {
const s = Number(__ENV.BURST || 20) / LIMIT_RPS + 1;
for (let i = 0; i < s; i += 1) { /* k6 sleep imported lazily to keep the example short */ }
}Gotchas and edge cases
- The generator becomes the bottleneck. If k6 cannot sustain the offered rate,
dropped_iterationsclimbs and your plateau is the load generator’s, not the limiter’s. Watch that metric and raisemaxVUsor distribute the run. - A shared staging Redis. Another team’s test against the same store changes your numbers. Use a dedicated database index and a unique key prefix.
- Testing through a cache. A CDN or reverse-proxy cache serving 200s without touching the origin inflates the accepted count. Add a cache-busting query parameter or a
Cache-Control: no-storerequest header. - Wall-clock alignment. Fixed-window limiters reset on a boundary; a 60-second test starting mid-window measures across two partial windows. Run for a multiple of the window, or start on a boundary.
- Counting 200s only. A limiter that returns 503 under stress will look like it accepted nothing; count all statuses and assert the rejection code explicitly.
- Ignoring latency added by the limiter. Record
http_req_durationfor accepted requests and compare with a run against an unlimited route: the delta is the limiter’s overhead and should be a couple of milliseconds.
Verification and testing
# Run against staging with the production node count.
LIMIT_RPS=100 BURST=20 TEST_KEY=$STAGING_LOADTEST_KEY \
k6 run --summary-export=ratelimit-summary.json ratelimit.test.js
# The three numbers that matter, extracted for a CI artifact:
jq '{
accepted: .metrics.rl_accepted.values.count,
rejected: .metrics.rl_rejected.values.count,
contract_ok: .metrics.rl_contract_ok.values.rate,
dropped_iterations: .metrics.dropped_iterations.values.count
}' ratelimit-summary.json
# {
# "accepted": 6012, <- 100/s x 60 s: correct
# "rejected": 11988,
# "contract_ok": 1,
# "dropped_iterations": 0 <- generator kept up; the plateau is real
# }Store accepted alongside the release identifier. A limiter that measured 6,012 last month and 17,900 today did not get faster — its counter stopped being shared, and the load test is the only thing that would have noticed.
Frequently Asked Questions
Why must the test use a single API key?
Because the limit is per identity. Spreading load across many keys measures your total capacity and leaves every individual bucket well under its threshold, so the limiter never makes a decision. One key, deliberately over-driven, is the only shape that measures the ceiling.
How long should the test run?
At least sixty seconds, and a whole number of limiter windows. Short runs are dominated by the initial burst allowance, which makes the accepted rate look higher than the sustained limit. For minute-long windows, three to five minutes gives a plateau you can read confidently.
Can I run this against production?
Against a canary slice with a dedicated test key, yes — and it is often more truthful than staging because the node count and store topology are real. Label the key so dashboards and alerts exclude it, keep the offered load small relative to production traffic, and never reuse a customer's key.
What if the accepted rate is slightly above the configured limit?
A few per cent is usually the burst allowance being consumed at the start of the run plus window-boundary effects. A consistent overshoot of tens of per cent, or a noisy result that varies between runs, points at a non-atomic counter update — move the whole decision into a single atomic script.
Related
- Rate Limit Testing & Validation — the parent guide covering all four test layers.
- Unit Testing Rate Limit Middleware — the fast tests that run before this one.
- Verifying Rate Limits in CI Pipelines — turning these assertions into a merge gate.
- Rate Limiting Algorithm Benchmarking Guide — benchmarking the algorithm rather than the deployment.