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

Two load-test shapes and what each one measures Many virtual users with many keys measures total throughput and never engages the limiter, while a constant arrival rate against one key measures the accepted plateau, which is the limiter's real ceiling. Design the scenario around the question 500 VUs, 500 keys measures: your throughput limiter never engaged 0 rejections looks like success proves nothing about limits constant rate, one key measures: accepted plateau offered load exceeds the limit rejections are the signal answers the actual question use a constant arrival rate, not constant VUs: you must control offered load, not 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

javascript
// 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 */ }
}
Reading the result: three plateaus and their diagnoses An accepted rate matching the configured limit means the shared counter works; a rate matching the limit times the node count means counters are per instance; a noisy rate above the limit means the counter update is not atomic. What the accepted count tells you 6,000 in 60 s = the configured 100/s shared counter correct record it as the baseline 18,000 in 60 s = 3 x the limit per-instance counters published number is fiction 6,400 ± 300 slightly over, noisy non-atomic update move the decision into Lua the diagnosis is in the ratio to the configured number, not in the absolute count Dropped iterations invalidate the measured plateau When the generator sustains the configured arrival rate the plateau reflects the limiter, but when dropped iterations appear the offered load was lower than configured and the plateau reflects the load generator instead. Check this metric before trusting the result dropped_iterations = 0 offered load was what you asked for the plateau is the limiter's ceiling dropped_iterations > 0 the generator could not keep up the plateau is your load machine raise maxVUs or distribute the run, then re-measure before drawing conclusions

Gotchas and edge cases

  • The generator becomes the bottleneck. If k6 cannot sustain the offered rate, dropped_iterations climbs and your plateau is the load generator’s, not the limiter’s. Watch that metric and raise maxVUs or 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-store request 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_duration for 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

bash
# 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.