Skip to content
intermediate Phase 13 · Performance Testing

Load Scripting with k6

Write JavaScript load scripts with scenarios, thresholds, and correctness checks.

1h 5m
0 problems
Topic Progress 0%

Anatomy of a k6 Script: options, setup(), and the Default Function

qa-k6-load-scripting-anatomy -->

k6 keeps an entire load test in one JavaScript module you can read in a minute and review in a pull request. That file-ness is the appeal over GUI-built plans such as the one in Designing JMeter Test Plans: the load model, the journey, and the pass criteria all live in version control next to the code they exercise.

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

const errorRate = new Rate('business_errors');

export const options = {
  vus: 10,                    // warm-up only; scenarios below own the real model
  duration: '15m',
  thresholds: {
    http_req_duration: ['p(95)<400', 'p(99)<1200'],
    http_req_failed: ['rate<0.005'],
    business_errors: ['rate<0.01'],
  },
};

export function setup() {
  // runs once, before any VU — seed data, login tokens, ids
  const res = http.post('https://api.example.com/login', JSON.stringify({
    user: 'load-runner', pass: __ENV.K6_PASS,
  }), { headers: { 'Content-Type': 'application/json' } });
  return { token: res.json('token') };
}

export default function (data) {
  const res = http.get('https://api.example.com/orders', {
    headers: { Authorization: `Bearer ${data.token}` },
  });
  const ok = check(res, {
    'orders 200': (r) => r.status === 200,
    'has line items': (r) => Array.isArray(r.json('items')) && r.json('items').length > 0,
  });
  errorRate.add(!ok);
  sleep(Math.random() * 2 + 1);   // 1–3 s think time, per virtual user
}

export function teardown() {
  // runs once after all VUs stop — delete what setup() created
}

The three lifecycle pieces each have a distinct cardinality, and mixing them up is the most common beginner bug: setup() and teardown() run once per test, while the default function runs once per iteration, per virtual user. Anything expensive or stateful — the login, the token refresh, the seeded order id — belongs in setup() and flows into every VU via the function's argument. Login inside the default function models every user re-authenticating on every click, which loads the auth service in a way production never does. setup() can also fail the whole test by returning a Response that k6 treats as a failed HTTP request — useful when the load run should refuse to start against a dead environment.

The default function's body is plain JavaScript with a small standard library: http.get/post/request for calls, check for assertions, sleep for think time, and group('checkout', () => {...}) to bucket related samples under one label in the summary. Every request emits built-in metrics — http_req_duration, http_reqs, iterations, data_sent, data_received, http_req_failed — which is why the thresholds block can reference them without any setup.

The options block is the load model. vus plus duration is the simplest shape: N users looping as fast as think time allows (a closed model). stages replaces the flat pair with a ramp curve:

stages: [
  { target: 100, duration: '2m' },   // ramp up
  { target: 100, duration: '10m' },  // hold at expected peak
  { target: 0,   duration: '1m' },   // ramp down, watch recovery
]

Run it with k6 run checkout.js, override anything from the CLI (k6 run --vus 200 --duration 30m checkout.js, --env K6_PASS=..., --secret file://./secrets.json K6_PASS), and let the thresholds — declared in the file — decide the exit code. Thresholds are what turn a load script into a test: the run exits code 99 when they fail, so CI needs no wrapper script to gate a release. Note the third threshold above gates a custom metric rather than a k6 builtin — a plain http_req_failed gate would happily pass a run where every response was a 200 carrying an error body. Chapter three builds that separation of correctness from transport-level success, and it is the part interviewers push hardest.

Modeling a Traffic Mix: Scenarios, Executors, and the Open-vs-Closed Question

qa-k6-load-scripting-scenarios -->

One flat vus: 200 test assumes 200 identical users doing the same thing — which is a decent model of a demo and a poor model of your traffic. Production is a mix: catalog browsing at 200 rps, cart updates at 40 rps, checkout at 10 rps, each with its own shape. k6's scenarios express exactly that, and choosing the right executor per scenario is where load modeling becomes a real design decision.

export const options = {
  scenarios: {
    browse: {
      executor: 'constant-arrival-rate',
      rate: 200, timeUnit: '1s',
      duration: '10m',
      preAllocatedVUs: 100, maxVUs: 400,
      exec: 'browseCatalog',
    },
    checkout: {
      executor: 'ramping-arrival-rate',
      startRate: 5, timeUnit: '1s',
      stages: [
        { target: 20, duration: '3m' },  // sale starts
        { target: 20, duration: '5m' },
        { target: 5,  duration: '2m' },
      ],
      preAllocatedVUs: 60, maxVUs: 200,
      exec: 'placeOrder',
    },
    soak_users: {
      executor: 'constant-vus',
      vus: 25, duration: '6h',
      exec: 'sessionJourney',
    },
  },
};

Each scenario names the exported function it runs (exec), so one file can hold several journeys and the traffic mix becomes data. The executors fall into two families, and the difference is a favorite interview question:

Family Executors Arrival behavior Answers
Closed constant-vus, ramping-vus, shared-iteration Users arrive, finish, think, then send the next request — arrival rate depends on response time "Can the system serve N concurrent users?"
Open constant-arrival-rate, ramping-arrival-rate Requests arrive on a schedule no matter how slow the system is "Can it sustain R requests per second?"

The trap is a closed model's self-defeating feedback loop: when the server slows to 2 s per request, 100 VUs with 1 s think time drop from ~66 rps to ~33 rps — the test throttles itself exactly when you need the load most, and latency readings hide the collapse. An open executor keeps injecting at 200 rps regardless, so latency and queueing grow visibly: that is what reveals the ceiling. Use arrival-rate executors for SLO-shaped questions (rps against a latency budget) and VU executors for session-shaped questions (journeys, soaks, browser workloads).

Two practical details bite everyone once. preAllocatedVUs is a pool k6 fills eagerly; if real demand exceeds it, k6 allocates up to maxVUs, and if that is exhausted the run aborts with creating VUs would exceed maxVUs — meaning your load model was starved, and every metric before the abort is garbage. Size maxVUs with headroom and watch the vus_max gauge. And arrival-rate scenarios must carry their own think time (or run hot loops), because there is no idle user pacing the script.

Scenario definitions also carry per-scenario knobs that keep one file from becoming a monolith: tags (every sample from the scenario inherits them, so a Grafana query can filter scenario:checkout), env overrides, and startTime for sequencing — a checkout scenario delayed by 30s can begin only after a warmup scenario has exercised the caches. Graceful shutdown matters for soak runs: k6 sends the SIGINT-equivalent at duration's end and waits out in-flight iterations, so a POST that takes 4 s is not cut off mid-flight and miscounted as a failure. The rule of thumb for choosing executors in interviews: match the unit of traffic you can measure in production — if your analytics say "200 requests per second to /catalog," model it as an arrival rate; if they say "200 concurrent sessions," model it with VUs.

Scenarios decide when requests arrive; the next chapter decides what makes a run red.

Checks, Thresholds, and Custom Metrics: Making the Run Fail for the Right Reasons

qa-k6-load-scripting-checks -->

A load run that completes is not a load run that passes. k6 separates three concerns — correctness, performance, and reporting — and keeping them separate is what makes failures interpretable three weeks later.

Checks are assertions that never stop the run. check(res, {...}) records pass/fail per key and moves on, so a broken build still produces a full latency picture instead of aborting at the first 500. That is deliberate: you want to know whether p95 was still acceptable while responses were wrong. The catch is that a failing check alone changes nothing about the exit code — the run sails to code 0 with 40 % of checks red. Close the loop with a threshold:

thresholds: {
  checks: ['rate>0.99'],                       // correctness gate
  http_req_duration: ['p(95)<400', 'p(99)<1200'], // performance gate
  http_req_failed: ['rate<0.005'],             // transport gate
  'http_req_duration{expected_response:true}': ['p(95)<400'],
}

The tagged threshold on expected_response:true matters: without it, a burst of 500s lowers your average duration (failures return fast), and the latency gate goes green while the service is on fire. Thresholds also accept escalation:

thresholds: {
  http_req_duration: [{
    threshold: 'p(95)<400',
    abortOnFail: '5s',      // stop evaluating until 5 s of data exist
    delayAbortEval: '10s',  // keep collecting this long after first breach
  }],
}

abortOnFail turns a hopeless soak into a five-minute signal instead of burning six hours of CI — but only after delayAbortEval has gathered enough evidence to file a good bug.

Custom metrics carry the business meaning that HTTP status codes cannot. A 200 from /search?q=laptop with zero results is a failure; so is an order whose total does not match its line items:

import { Trend, Rate } from 'k6/metrics';
const searchHits = new Rate('search_results_found');
const orderTotal = new Trend('order_total_delta');   // a Trend, not a counter: deltas are continuous

export function browseCatalog() {
  const res = http.get(`${BASE}/search?q=laptop`);
  searchHits.add(res.json('results.length') > 0);   // 200 with zero results is a failure
}
export function placeOrder() {
  const res = http.post(`${BASE}/orders`, JSON.stringify(cart), { headers: auth });
  const serverTotal = res.json('total');
  orderTotal.add(Math.abs(serverTotal - cart.expectedTotal));  // pricing must match line items
}

A Rate on missing search results and a Trend on total deltas become thresholds of their own (search_results_found: ['rate>0.98'], order_total_delta: ['p(99)<0.01']), which is how a performance run doubles as a correctness run at scale.

Finally, get the data where people look: k6 run --out influxdb=localhost:8086/k6 checkout.js streams samples into InfluxDB for a live Grafana dashboard, and --summary-export summary.json (or a handleSummary hook) hands CI a machine-readable artifact to diff against your baseline.

The last mile is CI hygiene. Run warm-up iterations before measurement (scenarios.warmup with startTime offsets, or discard the first minute in the summary hook) so cold caches and TLS handshakes do not land in the p99 you gate on. Keep secrets out of the script: --secret file://./secrets.json K6_API_TOKEN injects them at run time, and __ENV.NAME reads ordinary configuration, so one script serves staging and production-shaped runs with -e BASE_URL=.... And when the run is flaky, suspect the generator before the service — a CI runner sharing a vCPU with a build throttles its own HTTP client, showing latency the server never caused. The exported summary is exactly the handoff to Analyzing Results and Bottlenecks — the next step is explaining why the p95 moved, with server-side evidence.

Quiz

1. Under a constant-vus scenario with 1 s think time, server latency doubles from 1 s to 2 s. What happens to request throughput?

Question 1 options

2. A run completes with 40 % of its checks failing but exits 0. What must you add?

Question 2 options

3. An arrival-rate run aborts with "creating VUs would exceed maxVUs". Which statement is correct?

Question 3 options

4. Why gate http_req_duration{expected_response:true} in addition to the untagged metric?

Question 4 options

Flashcards

Question

Cardinality of setup(), the default function, and teardown().

Answer

setup() runs once per test before any VU (login, token, seed data — returned state flows into every VU via the default function argument); the default function runs once per iteration per VU; teardown() runs once after all VUs stop (cleanup). Login inside the default function models every user re-authenticating every click.

Question

Open versus closed workload models in k6.

Answer

Closed (constant-vus, ramping-vus): users finish, think, then send again — throughput falls when the server slows, self-throttling the test. Open (constant-arrival-rate, ramping-arrival-rate): requests arrive on schedule regardless of latency, so queueing shows up as growing duration — right for rps-vs-SLO questions.

Question

What preAllocatedVUs and maxVUs do, and the failure mode.

Answer

preAllocatedVUs is the pool k6 fills eagerly; beyond it k6 allocates up to maxVUs, and exhausting maxVUs aborts the scenario ("creating VUs would exceed maxVUs"). Metrics after the abort reflect an under-loaded run — size both with headroom and watch the vus_max gauge.

Question

How a k6 run fails, and why checks need help.

Answer

Thresholds control the exit code: breach = exit 99, so CI needs no wrapper. Checks never fail a run on their own — pair them with checks: [rate>0.99]. abortOnFail stops a doomed soak early, while delayAbortEval keeps collecting long enough to file a useful bug.

Question

Custom metrics and tagged thresholds: what problem they solve.

Answer

Custom Rate/Trend metrics carry business meaning HTTP codes cannot (search returned results, order total delta), making correctness gateable at load. Tagged thresholds like http_req_duration{expected_response:true} exclude fast-failing responses that would otherwise drag percentiles down and mask a regression.

Revision Notes

Key Takeaways

  • 1. setup()/teardown() run once per test; the default function runs per VU per iteration — stateful work belongs in setup()
  • 2. Open executors hold the arrival rate when the server slows; closed executors silently reduce load exactly when latency spikes matter
  • 3. Thresholds in the script are the gate: k6 exits 99 on breach, so CI needs no wrapper — but checks need a threshold of their own to matter
  • 4. maxVUs exhaustion aborts the scenario and invalidates the tail of the results; size the VU pool with headroom
  • 5. Tagged duration thresholds and custom Rate/Trend metrics keep correctness and performance separable inside one run

Interview Tips

  • • "How is k6 different from JMeter?" — code-as-test in version control, scenarios with open vs closed executors, thresholds embedded so runs self-gate at exit 99; mention the arrival-rate model most GUI tools lack
  • • "Why is my throughput dropping as the server slows?" — closed-model self-throttling: VUs wait out the latency, so switch to constant-arrival-rate for a true rps test
  • • "How do you fail a load test in CI?" — thresholds in options (p95/p99, error rate, checks rate); abortOnFail with delayAbortEval for fast feedback on hopeless runs
  • • "How do you test correctness under load?" — checks for assertions plus custom Rate/Trend metrics on business values, and tagged http_req_duration{expected_response:true} so failures cannot skew percentiles

Cheat Sheet

Load Scripting with k6

Script shape:

export const options = {
  stages: [{ target: 100, duration: '2m' }, { target: 100, duration: '10m' }, { target: 0, duration: '1m' }],
  thresholds: {
    http_req_duration: ['p(95)<400', 'p(99)<1200'],
    http_req_failed: ['rate<0.005'],
    checks: ['rate>0.99'],
  },
};
export function setup() { /* once: login, seeds */ }
export default function (data) { /* per VU per iteration */ sleep(1); }

Executors: closed = constant-vus / ramping-vus (session-shaped; self-throttles when slow) · open = constant-arrival-rate / ramping-arrival-rate (rps-shaped; queueing becomes visible). Size preAllocatedVUs + maxVUs with headroom or the run aborts.

Gates: thresholds decide the exit code (99 on breach); checks need their own threshold; abortOnFail + delayAbortEval abort hopeless soaks with evidence; gate latency on http_req_duration{expected_response:true} so fast failures cannot flatter percentiles.

Custom metrics: new Rate('search_results_found'), new Trend('order_total_delta') — business correctness becomes a threshold.

Run/observe: k6 run --vus 20 --duration 30m --env K6_PASS=... script.js · --out influxdb=.../k6 for live Grafana · --summary-export summary.json for CI diffs against the baseline.

Continue Learning