Skip to main content

k6 Stress Test: Scripts, Breaking Points and Recovery

How to write a k6 stress test, with real runs: a ramping-vus test passed at 4x load with 0% errors, while an arrival-rate test at 2x capacity failed 50%.

k6 Stress Test: Scripts, Breaking Points and Recovery

A k6 stress test runs your script at a load above normal traffic, holds it there, then ramps down to see whether the system recovers. In k6 you write it as stages: a ramp to normal load, a ramp past it, a hold, and a ramp back down, with thresholds that turn “too slow” or “too many errors” into a failed run.

The executor you choose decides what the test can find. We ran the same stress profile against a small API that handles about 370 requests a second. With ramping-vus, four times the normal number of users produced 0% errors and passed every threshold. With ramping-arrival-rate at twice the server’s capacity, half of the requests sent at the peak failed. Both are correct k6 tests; only one of them stressed the server.

Key Takeaways

  • The load ramps beyond normal, holds, and ramps down. Recovery after the ramp-down is part of the result.
  • With ramping-vus and no think time, users wait for slow responses, so the request rate stays flat and the server never overloads. Our 80-user run passed with 0% errors.
  • ramping-arrival-rate keeps sending requests at the planned rate, which is what pushes a system into errors. Our run at 800 requests a second failed half of them.
  • For a breaking-point test, add abortOnFail thresholds. Ours stopped the run at 52 seconds with exit code 99, after the served rate had stalled at about 390 requests a second, the server’s measured capacity.
  • Read latency for successful requests separately: fast error responses pulled our median down to 1.2 ms at the worst moment.

What is a stress test in k6?

In k6, this kind of test is a script whose load shape goes above the traffic you expect, stays there long enough for problems to build, then comes back down. k6 has no special mode for it: the shape comes from the stages of a ramping executor, and pass or fail comes from thresholds. Grafana’s testing guide defines it as testing “how the system performs when loads are heavier than usual”.

How much heavier depends on the risk you are testing for. Grafana’s guide sets no fixed percentage: sometimes a few points above average load, often 50% to 100% higher, with a longer ramp for a bigger increase. It also says to “only run stress tests after running average-load tests”, which matches what we found: you need the normal-load numbers to tell when the stressed ones are bad. A load test and a stress test differ mainly in that target and in what counts as passing.

How do you write a k6 stress test script?

Use stages to ramp to normal load, hold, ramp to the stress level, hold again, and ramp down to zero, then add thresholds for error rate and response time. The script below is the one we ran first. Normal load is 20 virtual users, stress is 80, and each stage lasts 15 to 20 seconds so the whole run fits in under two minutes.

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  stages: [
    { duration: '15s', target: 20 },  // ramp to normal load
    { duration: '20s', target: 20 },  // hold at normal
    { duration: '15s', target: 80 },  // ramp to 4x normal
    { duration: '20s', target: 80 },  // hold under stress
    { duration: '15s', target: 0 },   // ramp down: does it recover?
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<300'],
  },
};

export default function () {
  const res = http.get('http://127.0.0.1:8099/api');
  check(res, { 'status is 200': (r) => r.status === 200 });
}

In a real test, make each hold several minutes long and the ramps proportionate. Grafana’s own example ramps to 200 users over 10 minutes and holds for 30. Short stages are fine for trying a script, not for finding problems that need time to build, such as connection pools filling up or memory growing. Problems that take hours to build, such as a slow memory leak, are a soak test’s job.

How we tested

Our target was a Node.js API built to have a known limit: 8 workers, each request taking 20 ms of work, so the ceiling is about 400 requests a second, and a measured 367 with hey at 32 connections. Requests beyond what the workers can take wait in a queue, and once 200 are waiting, new ones get a 503 straight away. That is roughly how a real service with a fixed thread pool and a load-shedding limit behaves. We ran k6 v2.3.0 on the same machine, an Apple M1 Max, and split each run’s JSON output into 5-second buckets to see the curve, not only the end-of-test summary.

What happened with ramping-vus?

The test passed every threshold with 0% errors, even at four times the normal user count. The request rate stopped growing at about 370 a second once 13 users were active and stayed there all the way to 80 users. What grew was the response time: the median went from 22 ms with 7 users to 60 ms with 20 and 219 ms with 80.

TimeVUs at end of intervalRequests/sErrorsMedianp95
0-5 s71690%22 ms23 ms
5-10 s133650%22 ms44 ms
20-25 s (normal)203710%60 ms66 ms
50-55 s (stress)803650%219 ms222 ms
80-85 s (ramp-down)27 to 13200%43 ms67 ms

This is what a closed model does. Each virtual user sends a request, waits for the answer, and only then sends the next. When the server slows down, every user slows with it, so the arrival rate cannot rise above what the server already handles. Response time grows with the number of users in line (80 users ÷ 370 requests a second ≈ 216 ms, close to the measured median), and the queue in front of the server never reaches the point where it starts rejecting work.

Two things follow. The “normal” load of 20 users was already past this server’s knee, which is visible only because requests per second stopped rising at 13 users. And the run did not test overload at all: a real traffic spike does not slow down because your server did. Adding think time with sleep() makes each user send less often, which pushes the knee to a higher user count, but the same limit stays. A closed model can never send more than the server answers.

What happened with ramping-arrival-rate?

The server broke, as it would under a real spike. ramping-arrival-rate starts a set number of iterations each second whatever the response times, adding virtual users up to maxVUs to keep up. At 200 a second, half the server’s capacity, every request succeeded in about 21 ms. Past capacity, the queue filled, and at 800 a second exactly half of all requests came back as 503.

export const options = {
  scenarios: {
    stress: {
      executor: 'ramping-arrival-rate',
      startRate: 100,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 300,
      stages: [
        { duration: '20s', target: 200 },  // normal: half of capacity
        { duration: '20s', target: 200 },
        { duration: '20s', target: 800 },  // 2x capacity
        { duration: '20s', target: 800 },
        { duration: '20s', target: 100 },  // back down: recovery
      ],
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<300'],
  },
};
TimeTarget rateRequests/sErrorsMedianp95
20-25 s200/s2000%20 ms22 ms
45-50 srising to 500/s3870%61 ms251 ms
50-55 srising to 650/s54327%485 ms525 ms
60-65 s800/s80050%1.2 ms520 ms
90-95 sfalling to 275/s4012.2%510 ms527 ms
95-100 sfalling to 100/s1870%21 ms22 ms

The run failed both thresholds: 30.43% of 41,834 requests failed, the overall p95 was 525.62 ms, and k6 exited with code 99, which is what a CI job needs to stop a release. It also counted 165 dropped iterations, 161 of them during the climb while k6 was still adding virtual users. The k6 scenarios guide covers how to size preAllocatedVUs and maxVUs so drops do not hide part of the load.

Recovery was quick here: within 5 seconds of the target falling below capacity, errors were back to 0% and p95 to 22 ms. That is the result you want to see. A system that stays slow after the load drops (a queue still draining, a circuit breaker still open, a thread pool that never shrank) has a problem that the peak numbers alone would not show.

Why did the median get faster under stress?

Because failed requests count in http_req_duration, and our failures were fast. Between 60 and 65 seconds, half of all responses were 503 replies that took about a millisecond, so the median fell to 1.2 ms while every successful request waited around half a second. A p50 that improves as the error rate climbs is a sign that errors are mixed into the latency figures.

k6 tags every response with expected_response, so the fix is one line: put the latency threshold on successful responses only.

thresholds: {
  http_req_failed: ['rate<0.01'],
  'http_req_duration{expected_response:true}': ['p(95)<300'],
},

The end-of-test summary prints that sub-metric under http_req_duration as { expected_response:true }. Under overload, read that line, not the combined one. The same reasoning applies to percentiles in general: a summary statistic is only as meaningful as the set of requests it covers.

How do you find the breaking point with abortOnFail?

Keep raising the arrival rate until a threshold fails, and let that threshold stop the test. That is a breakpoint test: the same overload idea with no planned peak. Set abortOnFail: true on the thresholds that define “broken”, and delayAbortEval so a noisy first few seconds do not end the run. The rate just before it stops is roughly your breaking point, since k6 aborts slightly after the break.

export const options = {
  scenarios: {
    breakpoint: {
      executor: 'ramping-arrival-rate',
      startRate: 50,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 500,
      stages: [{ duration: '2m', target: 1000 }],  // keep climbing until something breaks
    },
  },
  thresholds: {
    http_req_failed: [{ threshold: 'rate<0.01', abortOnFail: true, delayAbortEval: '5s' }],
    'http_req_duration{expected_response:true}': [
      { threshold: 'p(95)<200', abortOnFail: true, delayAbortEval: '5s' },
    ],
  },
};

Our run stopped at 52 seconds with exit code 99 and the message thresholds on metrics 'http_req_duration{expected_response:true}' were crossed; at least one has abortOnFail enabled, stopping test prematurely. The latency threshold tripped first; the error rate was still 0%, because the queue filled with slow requests before the server began rejecting any. The last healthy bucket served 385 requests a second, and the first bad one 394 with a p95 of 255 ms, so the measured break sat right at the capacity we had calibrated beforehand.

Three details matter when you read a breakpoint result. Thresholds are evaluated over the whole run so far, not over the last few seconds, so k6 aborts a little after the moment things went wrong. The 177 iterations that were running when the test stopped were interrupted and not counted as failures. And Grafana’s guide recommends ramping-arrival-rate for this test, because a closed model slows itself down as the system degrades, as our first run showed. The main site’s breakpoint testing entry covers the concept.

What should you watch while k6 pushes past capacity?

Watch requests per second, error rate, and latency of successful requests over time, not only in the end-of-test summary. The summary averages the whole run into one line, and in our arrival-rate run that line could not show that errors appeared at 50 seconds, peaked at 60 and were gone by 95. A per-second view shows the knee, the failure and the recovery separately.

Five signals told us most of what happened: requests per second that stop growing while load grows (the knee), the error rate and its status codes (503 here, which means shedding, unlike 500 or timeouts), p95 of successful requests only, dropped_iterations in arrival-rate runs, and how long after the ramp-down everything returns to the normal-load numbers. Server-side metrics such as CPU, queue length and connection pool use explain why the curve bent where it did. For live charts during a local run, k6’s web dashboard (K6_WEB_DASHBOARD=true) is covered in the k6 reports guide.

How to push past capacity with k6 on LoadFocus

LoadFocus runs k6 scripts on the stock k6 v2.3.0 binary from 25 cloud regions, so the load can go beyond what one laptop can send. The load shape comes from the run settings rather than the script: the users, ramp-up time and steps, and duration you set replace the script’s own stages, and every scenario, arrival-rate ones included, runs with a set number of users. That is the closed model from our first run, and the ramp ends in a hold with no ramp-down.

Two adjustments make it a real overload test on k6 load testing with LoadFocus. Set more users than the server can hold in progress at once, its worker count plus its queue, so that requests pile up and overload shows as errors rather than only as slower responses. Then run a short test at normal load straight afterwards to check recovery. The script’s checks and thresholds still run inside k6 on each load generator, and an abortOnFail threshold still stops the generator where it trips. The run’s PASS or FAIL comes from the pass/fail thresholds set in the app: maximum p95 and p99 response time, maximum error rate and minimum throughput.

Frequently Asked Questions

How do you do a stress test in k6?

Write a script whose stages ramp to normal load, hold, ramp past it to the stress level, hold again, and ramp down to zero. Add thresholds for error rate and for latency of successful responses so the run fails when they are crossed. Run an average-load test first so you know what normal numbers look like.

What is the difference between a stress test and a breakpoint test in k6?

A stress test goes to a planned level above normal, holds it, and checks behaviour and recovery. A breakpoint test has no planned peak: it keeps raising the load until a threshold fails and uses abortOnFail to stop there. The load just before it stops is roughly the breaking point, since k6 aborts slightly after the break. Grafana recommends the ramping-arrival-rate executor for breakpoint tests.

Should a k6 stress test use ramping-vus or ramping-arrival-rate?

Use ramping-arrival-rate when you want to know what happens when traffic arrives faster than the system can serve it. With ramping-vus, each user waits for its response, so a slow server receives fewer requests and never overloads. In our runs, 80 users produced 0% errors while 800 requests a second produced 50% errors against the same API.

Why did my k6 stress test pass with 0% errors?

Often because the test used a fixed number of virtual users with no think time. Each user waits for a reply before sending the next request, so the request rate stops at whatever the server can answer, and the overload shows up as slower responses rather than errors. Check whether requests per second kept rising as you added users. If it went flat, you found the knee.

What exit code does k6 return when a threshold fails?

k6 returns exit code 99 when a threshold fails, both at the end of a run and when an abortOnFail threshold stops the test early. A run where only checks fail, with no threshold on them, still exits 0. CI jobs should rely on thresholds, not on the summary text.

Sources

  • Stress testing and breakpoint testing guides, and the thresholds reference (abortOnFail, delayAbortEval): Grafana k6 documentation, grafana.com/docs/k6/latest/testing-guides/test-types/stress-testing/, grafana.com/docs/k6/latest/testing-guides/test-types/breakpoint-testing/ and grafana.com/docs/k6/latest/using-k6/thresholds/, read 7 November 2026.
  • LoadFocus k6 runs: loadfocus.com/k6-load-testing and the LoadFocus guide to running a k6 load test (stock k6 v2.3.0), read 7 November 2026; how run settings replace a script’s load shape, from the LoadFocus k6 agent’s source code, 7 November 2026.
  • Test runs: k6 v2.3.0 (official macOS arm64 release, checksum-verified) and hey 0.1.5, against a local Node.js 23.11.1 API with 8 workers, 20 ms per request and a 200-request queue, on an Apple M1 Max, 7 November 2026. Timings show patterns, not benchmarks.

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.