k6 spike test example: script, thresholds and how to read the result

A spike test hits a system with a sudden, short jump in traffic and then drops back to normal. It answers two questions. Does the system survive the jump, and does it recover once the jump is over? In k6 you build one with a short ramping-vus or ramping-arrival-rate stage that goes up steeply, holds briefly, and falls again.

Below is a script you can run as is against the public QuickPizza demo that Grafana hosts for k6 examples, with the thresholds explained and the output read line by line. For the full family of test types (load, stress, soak, spike) see load testing types with k6 examples. This post goes deep on one.

Spike vs stress vs load

Load testStress testSpike test
QuestionIs performance fine at expected traffic?Where does it break?Does it survive a sudden jump and recover?
Traffic shapeRamp up, hold at expected levelKeep ramping past expected levelJump fast, hold briefly, drop fast
DurationMinutes to an hourUntil something failsMinutes
What you watchLatency and errors at steady stateThe breaking pointErrors during the jump and latency after it
Typical cause it modelsA normal busy dayGrowth over monthsA newsletter, a launch, a retry storm

The word that matters in the spike column is “recover”. A system that returns errors for twenty seconds and then returns to normal is a different finding from one that stays slow for ten minutes after the traffic is gone.

The script

Save this as spike.js. It holds ten virtual users (VUs) for a baseline, jumps to a hundred in ten seconds, holds, drops back, and then stays at ten to watch recovery.

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

export const options = {
  scenarios: {
    spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 10 }, // warm up to normal load
        { duration: '1m', target: 10 }, // baseline
        { duration: '10s', target: 100 }, // spike
        { duration: '1m', target: 100 }, // hold the spike
        { duration: '10s', target: 10 }, // drop back
        { duration: '2m', target: 10 }, // recovery
        { duration: '10s', target: 0 },
      ],
      gracefulRampDown: '10s',
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<1000'],
    checks: ['rate>0.99'],
  },
};

export default function () {
  const res = http.get('https://quickpizza.grafana.com/');
  check(res, {
    'status is 200': (r) => r.status === 200,
    'body has content': (r) => r.body && r.body.length > 0,
  });
  sleep(1);
}

Run it with k6 run spike.js, or with Docker if you have not installed k6:

docker run --rm -i grafana/k6 run - < spike.js

Two things about the target. QuickPizza is a shared public demo, so do not point a real spike at it. Treat it as a sandbox to learn the syntax, and use a hundred VUs only against something you own. For your own project, run this against a staging environment, never against production. If you have no staging, a local container is better than nothing.

Why ramping-vus and not the shorthand

You can write stages at the top level of options, and k6 will build the same scenario for you. I prefer the explicit scenarios form because it makes the executor visible and lets me add a second scenario later. The behaviour is the same.

VUs or arrival rate

ramping-vus is a closed model. Each VU waits for its response, sleeps, then loops. If the server slows down, the number of requests per second drops with it, which is not what happens when real users arrive from outside. For a spike, an open model is often closer to the truth. ramping-arrival-rate controls requests per second, not VUs:

scenarios: {
  spike: {
    executor: 'ramping-arrival-rate',
    startRate: 5,
    timeUnit: '1s',
    preAllocatedVUs: 50,
    maxVUs: 300,
    stages: [
      { duration: '30s', target: 5 },
      { duration: '10s', target: 100 }, // 100 iterations per second
      { duration: '1m', target: 100 },
      { duration: '10s', target: 5 },
      { duration: '2m', target: 5 },
    ],
  },
},

With this executor, if k6 cannot start iterations fast enough because maxVUs is too low, it reports dropped_iterations. Add dropped_iterations: ['count==0'] to the thresholds so you notice. I ran a shortened version of this scenario, with a few seconds per stage and a peak of 20 iterations per second, to check that the syntax and the threshold run correctly. It passed with zero dropped iterations. That says nothing about the full-size run.

Thresholds that mean something

Thresholds turn a graph into a pass or fail. Without them, k6 run exits 0 no matter how bad the results were.

  • http_req_failed: ['rate<0.01'] fails the run if more than one percent of requests fail. A spike is where you expect some failures, so pick a number that matches what you would accept in production.
  • http_req_duration: ['p(95)<1000'] fails the run if the 95th percentile is over one second. Choose a value from your own requirements. The one in the script is a placeholder.
  • checks: ['rate>0.99'] fails the run if more than one percent of checks fail. This catches the case where the server answers quickly with a 200 and an empty or wrong body.

You can also stop a run early when it is clearly failing, with abortOnFail:

thresholds: {
  http_req_failed: [
    { threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '30s' },
  ],
},

delayAbortEval gives the system time to warm up before k6 starts judging. Without it, a few failures in the first seconds can abort a run that would have been fine.

One more idea: you cannot set a threshold on only the spike phase by default, because metrics are aggregated over the whole run. If you want to assert separately on the spike and on recovery, split them into two scenarios with startTime and tag the requests, then use thresholds on the tagged sub-metrics.

Reading the output

I ran a compressed copy of the script from my laptop to check that it works: stages of three seconds instead of minutes and a peak of five VUs. That is not a spike, so treat the numbers as a format example. This is the part of the end-of-test summary you care about:

checks_succeeded...: 100.00% 74 out of 74
checks_failed......: 0.00%   0 out of 74

http_req_duration..: avg=428.3ms min=320.19ms med=350.12ms max=1.75s p(90)=508.76ms p(95)=837.71ms
http_req_failed....: 0.00%  0 out of 37
http_reqs..........: 37     1.751638/s

vus................: 1      min=0       max=5
vus_max............: 5      min=5       max=5

Line by line:

  • checks_succeeded counts every check() call, two per iteration here, which is why 37 requests gave 74 checks.
  • http_req_duration has avg, med and percentiles. The average hides the tail. Here the median is 350 ms while the maximum is 1.75 s, and p95 sits in between. Always read p95 or p99 on a spike test.
  • http_req_failed is the share of failed requests. It does not count a 500 as a check failure. It counts it as a failed request.
  • vus shows the current VU count at the end. vus_max shows the peak the run allocated. If vus_max is lower than your target, the ramp did not reach it.

If a threshold is crossed, k6 prints a red cross next to it and exits with a non-zero code. That exit code is what makes the test usable in CI. The thresholds on metrics ... have been crossed message in my compressed run came from the original p(95)<800 threshold, which my slow home connection exceeded at 837 ms. A good reminder that a threshold is a claim about your environment and network too.

What a good result looks like

A passing spike has three properties. Errors stay under your limit during the jump. Latency rises during the spike and returns to the baseline within a short time after the drop. And nothing needs a restart. The second property is the one people forget to check, so compare the p95 during the baseline stage with the p95 during the last two minutes. To see that per stage, tag requests with the stage name, or graph the run with --out to Grafana or another output.

Mistakes that make a spike test useless

  • Going from 0 to 100. The spike should start from a realistic baseline. Without it you are testing cold start, not a spike.
  • No recovery stage. Ending right after the drop means you never see whether the system comes back.
  • Testing a different system than production. One small staging instance with a shared database says little about a ten-node production cluster. Say what you tested.
  • Ignoring the client. The load generator can be the bottleneck. Watch k6’s own CPU, and if the machine is saturated, the latency you see is partly k6’s.
  • Using a closed model for traffic that is open. If real users arrive no matter how slow you are, use ramping-arrival-rate.
  • Hitting a third-party service. If your page calls an external API, the spike hits that too. Mock it or stub it in the test environment.

Where this fits

A spike test is cheap to run and expensive to interpret, so run it on a schedule (weekly is a reasonable start) against a stable environment and keep the summary. A single run tells you little. A run that gets worse from week to week tells you a lot. For the wider picture of what to measure and where, see what screenshot, UI, API and performance testing are.

A first step

Run spike.js against a local container of your own service with a baseline of two or three VUs. Change the spike target until a threshold fails. The point where it fails is your first real number, and it is yours, unlike any in a blog post.