Mert Özen Further With Every Line
Testing & Quality Performance and Load Testing — #03

Load Testing with k6: Scripts, Scenarios, Arrival-Rate Executors, Checks, Thresholds and expect()

Mert Özen Nov 14, 2026 12 min 15 views
Load Testing with k6: Scripts, Scenarios, Arrival-Rate Executors, Checks, Thresholds and expect()

A realistic load test with k6 2.x is built from one arrival-rate scenario per user flow, a VU count computed with Little's Law, per-endpoint thresholds, fail-fast expect() calls in setup() and a JSON summary. We run a TypeScript script against Bookstore and read the output line by line, show how abortOnFail interrupts the test when the database goes away, and how p95 grows nine times when k6 gets a single core.

Version note: The code in this post was built and run with .NET 10; the load tests were run with k6 2.4.0 (with k6-testing 0.6.1) and a TypeScript script against Bookstore.Api and SQL Server 2025 on Docker Compose.

Guess First

The load test for Bookstore, the online bookstore used as the shared example across this site's series, is ready: the traffic mix we derived in post 02, 40,000 books of data and the payment stub. Three questions. If a check fails while the test runs, does the test stop? If the database goes away in the middle of the test, what does k6 do: keep sending load until the end, or stop right away? Finally, if you give k6 a single CPU core without touching the server at all, does the reported p95 change? In this post we turn the workload model into a complete k6 test and answer each question with a real run.

k6 2.x and TypeScript

k6 2.0 went GA on May 12, 2026. At the time of writing the latest release is 2.4.0, published on November 12, 2026. 2.0 removed some long-deprecated commands and options (the externally-controlled executor, --no-summary and others) and brought Playwright-inspired expect() assertions and a native OpenTelemetry output. On Windows installation is one command:

winget install k6 --source winget
k6 version

k6 scripts are JavaScript, but k6 also runs a .ts file directly. That support has been on by default since v0.57. When k6 turns TypeScript into JavaScript with esbuild it strips the type information; it does not type-check. If you want to see type errors you need your editor or a separate tsc --noEmit step. The gain for us is that the shape of the data returned from setup() and of function parameters is written down in the code.

One Scenario per Flow: Arrival-Rate Executors

The script in post 02 picked a random flow per session in a single scenario. Here every flow gets its own scenario. That way the rate of each flow can be set separately (tripling checkout on a campaign evening, for example) and results are reported per scenario:

import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { expect } from 'https://jslib.k6.io/k6-testing/0.6.1/index.js';

// Bookstore average-load test: one open-model scenario per user flow, so every flow keeps its own rate.
// Usage: k6 run -e SCALE=1 bookstore.ts   (SCALE=2 doubles every rate)
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080';
const SCALE = Number(__ENV.SCALE || 1);
const DURATION = __ENV.DURATION || '5m';

const isbns = new SharedArray('isbns', () =>
    open('../data/isbns.txt').split(/\r?\n/).filter((line: string) => line.length === 13));
const terms = ['river', 'garden', 'kingdom', 'silent', 'lighthouse', 'library', 'winter', 'dune', 'austen', 'turing'];

function flow(exec: string, sessionsPerSecond: number, secondsPerSession: number) {
    const rate = sessionsPerSecond * SCALE;
    return {
        executor: 'ramping-arrival-rate',
        exec,
        startRate: 0,
        timeUnit: '1s',
        // Little's Law with 50% headroom, so k6 does not have to create VUs during the test.
        preAllocatedVUs: Math.ceil(rate * secondsPerSession * 1.5),
        maxVUs: 500,
        stages: [
            { target: rate, duration: '30s' },  // ramp-up (warm-up)
            { target: rate, duration: DURATION },
        ],
        tags: { flow: exec },
    };
}

ramping-arrival-rate is an open model: k6 starts new iterations at the rate written in stages without waiting for responses. Each iteration is one run of the function named in exec, that is, one session. Raising the rate from zero to the target in the first 30 seconds gives a warm-up phase. In post 02 we saw a cold start triple the median. A ramp-up reduces that effect but does not remove it from the result; if you want to leave warm-up out completely, write a separate warm-up scenario and bind the thresholds only to the main scenario's tag.

The difference from VU-based executors such as constant-vus and ramping-vus is the closed-model issue from post 01: there the iteration rate depends on the system's response time, here it does not. In an arrival-rate executor a VU is just a worker that carries an iteration. If all of them are busy, a new iteration cannot start and dropped_iterations grows. That metric tells you one of two things: either you did not reserve enough VUs up front, or the system slowed down, iterations got longer and VUs ran out. I rule out the first case by computing preAllocatedVUs with Little's Law. A browsing session lasts 9 seconds on average and 14 sessions arrive per second; that is 126 sessions at once, 189 VUs with 50 percent headroom. In the first attempt I gave every scenario a fixed 20 VUs; even a 15-second trial crossed the dropped_iterations threshold.

setup(), SharedArray and expect()

Test data is read in the init stage, the top-level code that runs while each VU is created. SharedArray reads the file once and all VUs use the same read-only copy. With a plain array, each of the 251 VUs would keep its own copy of 40,000 ISBNs.

setup() runs once before the load starts; the value it returns is passed to every iteration as a parameter. For an API that requires authentication, this is where you get the token once. Bookstore's reference application has no authentication, so here I verify that the environment is ready:

type SetupData = { categories: string[] };

export function setup(): SetupData {
    // Fail fast: if the environment is wrong, an assertion stops the test before any load is sent.
    const health = http.get(`${BASE_URL}/api/categories`);
    expect(health.status).toBe(200);
    const categories = (health.json() as { slug: string }[]).map((c) => c.slug);
    expect(categories.length).toBeGreaterThan(0);
    const book = http.get(`${BASE_URL}/api/books/${isbns[isbns.length - 1]}`);
    expect(book.status, 'test data is loaded (run seed-catalog.sql)').toBe(200);
    return { categories };
}

expect() is the assertion API introduced with k6 2.0. It is imported from an official jslib called k6-testing, not from a built-in module. The difference from check() is behavior. A failed check only produces a metric and the test goes on; that is exactly what you use it for, measuring what percentage of requests fail under load. A failed expect() interrupts the test: it says the scenario is invalid. That is why I use assertions not on requests under load but in setup(), to verify the environment. When I pointed the test at the wrong port, k6 sent a single request and stopped:

level=warning msg="Request Failed" error="Get \"http://localhost:5381/api/categories\": dial tcp 127.0.0.1:5381: connect: connection refused"
...
level=error msg="test aborted:

   Error: expect(received).toBe(expected)
      At: load/k6/bookstore.ts:57:31

Expected: 200
Received: 0

The exit code was 108, different from the 99 of a threshold failure. Telling the two apart is useful in CI: 108 says the script interrupted the test (here, the environment was not ready), 99 says the system missed its target. There is also an expect.soft() form: it marks the test as failed but does not interrupt it.

Tags, Groups and Per-Endpoint Metrics

Within a user flow, requests are both tagged and grouped:

export function checkout() {
    group('checkout', () => {
        const isbn = pickIsbn();
        viewBook(isbn);
        think(2, 5);
        const json = { headers: { 'Content-Type': 'application/json' } };
        const cart = http.post(`${BASE_URL}/api/carts`, null, { tags: { name: 'POST /api/carts' } });
        check(cart, { 'cart 201': (r) => r.status === 201 });
        const cartId = cart.json('id');
        http.put(`${BASE_URL}/api/carts/${cartId}/items/${isbn}`, JSON.stringify({ quantity: 1 }),
            { ...json, tags: { name: 'PUT /api/carts/{id}/items/{isbn}' } });
        think(5, 15);
        const order = http.post(`${BASE_URL}/api/orders`,
            JSON.stringify({ cartId, customerName: 'Grace Hopper', customerEmail: `grace.${__VU}@example.com` }),
            { ...json, tags: { name: 'POST /api/orders' } });
        check(order, { 'order 201': (r) => r.status === 201 });
    });
}

The name tag matters. By default k6 tags every request with its URL. 40,000 different ISBNs means 40,000 different time series. That bloats k6's memory and makes the result unreadable. With name, all requests to the same endpoint are collected under one template. The flow tag I gave each scenario and group() give a breakdown per flow. If you run with --summary-mode=full, the summary prints a separate section for every scenario and group.

Checks, Thresholds and abortOnFail

A check verifies a single response and writes its result to the checks metric. A threshold evaluates the whole test through a metric and decides whether the test passes. I wrote the acceptance criteria as thresholds per endpoint; the single, general p(95)<300 from post 01 would hide slow search behind fast book details:

    thresholds: {
        // Acceptance criteria from the SLO, per endpoint where it matters.
        'http_req_duration{name:GET /api/books/{isbn}}': ['p(95)<100'],
        'http_req_duration{name:GET /api/books?search}': ['p(95)<300'],
        'http_req_duration{name:POST /api/orders}': ['p(95)<500'],
        http_req_failed: [{ threshold: 'rate<0.005', abortOnFail: true, delayAbortEval: '30s' }],
        checks: ['rate>0.995'],
        // A dropped iteration is a session we wanted to start but could not: not more than 1 per 1,000.
        dropped_iterations: ['count<30'],
    },

abortOnFail stops the test as soon as the threshold is crossed instead of waiting for the end. delayAbortEval postpones that decision for the first 30 seconds, because a single error in the first few requests can show an error rate of 100 percent. I tried the situation from the guess question: about 75 seconds after the test started I stopped the SQL Server container (docker compose stop sql). The error rate rose and k6 interrupted the test 17 seconds after the stop:

    http_req_failed
    ✗ 'rate<0.005' rate=5.77%
...
    { name:GET /api/books/{isbn} }...: avg=679.35ms min=189µs    med=1.44ms   max=14.81s p(90)=4.13ms   p(95)=10.92s
...
level=error msg="thresholds on metrics 'checks, dropped_iterations, http_req_duration{name:GET /api/books/{isbn}}, http_req_duration{name:GET /api/books?search}, http_req_duration{name:POST /api/orders}, http_req_failed' were crossed; at least one has abortOnFail enabled, stopping test prematurely"

Exit code 99. Most errors were not a fast 500 but requests that waited close to 15 seconds and then failed. That is SqlClient's default 15-second timeout for opening a connection. This detail is information the load test gives you too: when the database is gone, the API keeps the response waiting for 15 seconds. How to fix that behavior with timeouts and a circuit breaker is in the System Design series post "Reliability Patterns". The answer to the first guess question is here as well: although checks failed, what stopped the test was not a check but a threshold with abortOnFail.

Reading the Output Line by Line

The main run: three flows, a 30-second ramp-up, then 3 minutes at a constant rate (14 browsing, 4 search and 2 checkout sessions per second). The payment stub runs with a 100-300 ms delay:

k6 run -e DURATION=3m --new-machine-readable-summary --summary-export summary.json bookstore.ts
     scenarios: (100.00%) 3 scenarios, 1500 max VUs, 4m0s max duration (incl. graceful stop):
              * browse: Up to 14.00 iterations/s for 3m30s over 2 stages (maxVUs: 189-500, exec: browse, gracefulStop: 30s)
              * checkout: Up to 2.00 iterations/s for 3m30s over 2 stages (maxVUs: 41-500, exec: checkout, gracefulStop: 30s)
              * search: Up to 4.00 iterations/s for 3m30s over 2 stages (maxVUs: 21-500, exec: search, gracefulStop: 30s)

  █ THRESHOLDS

    checks
    ✓ 'rate>0.995' rate=100.00%

    dropped_iterations
    ✓ 'count<30' count=0

    http_req_duration{name:GET /api/books?search}
    ✓ 'p(95)<300' p(95)=184.03ms

    http_req_duration{name:GET /api/books/{isbn}}
    ✓ 'p(95)<100' p(95)=3.21ms

    http_req_duration{name:POST /api/orders}
    ✓ 'p(95)<500' p(95)=301.8ms

    http_req_failed
    ✓ 'rate<0.005' rate=0.00%

  █ TOTAL RESULTS

    checks_total.......: 10917   47.982643/s
    checks_succeeded...: 100.00% 10917 out of 10917
    ...
    HTTP
    http_req_duration..................: avg=22.28ms  min=190µs    med=2.14ms   max=368.82ms p(90)=90.52ms  p(95)=163.08ms
      { expected_response:true }.......: avg=22.28ms  min=190µs    med=2.14ms   max=368.82ms p(90)=90.52ms  p(95)=163.08ms
      { name:GET /api/books?search }...: avg=131.64ms min=88.31ms  med=113.38ms max=292.94ms p(90)=175.09ms p(95)=184.03ms
      { name:GET /api/books/{isbn} }...: avg=1.63ms   min=190µs    med=1.39ms   max=152.17ms p(90)=2.48ms   p(95)=3.21ms
      { name:POST /api/orders }........: avg=215.99ms min=111.97ms med=216.69ms max=368.82ms p(90)=295.32ms p(95)=301.8ms
    http_req_failed....................: 0.00%  0 out of 11309
    http_reqs..........................: 11309  49.70557/s

    EXECUTION
    dropped_iterations.................: 0      0/s
    iteration_duration.................: avg=8.41s    min=2.1s     med=8.69s    max=20.03s   p(90)=11.78s   p(95)=13.71s
    iterations.........................: 3899   17.136972/s
    vus................................: 1      min=0          max=182
    vus_max............................: 251    min=251        max=251

Let us read it from top to bottom. The scenarios line says what will run: 3 minutes 30 seconds (including the ramp-up) and the VU ranges. First check that this is what you planned. The THRESHOLDS section is the decision itself: all six criteria passed, exit code 0. checks is 100 percent: no response had a status code other than the expected one. Do not look at timings without looking at the error rate; a fast error response makes p95 look good.

The general http_req_duration line (p95 163 ms) says little here, because it puts 1.6 ms book details and 216 ms orders into the same distribution. The real information is in the lines below. Search is under its limit (300) at a p95 of 184 ms but it is the most expensive read; it runs a LIKE '%term%' scan over 40,000 rows. About 200 ms of the order is the payment stub's delay. min and max look important at first glance but each represents a single request. We look at how to read percentiles in detail in post 06.

In the EXECUTION section dropped_iterations is 0: every requested session was started. iterations is 17.1 per second; shouldn't it be 20? No, because the first 30 seconds of the 3 minutes 30 seconds are ramp-up, which lowers the average iteration rate. The highest vus value is 182: a bit above Little's Law's estimate of 20 sessions × 8.41 seconds = 168, because the long checkout sessions pull it above the average. vus_max is 251: the total of the VUs we reserved.

A Summary for Machines: JSON

Terminal output is for people. To store runs, compare them or process them in CI, k6 can write a JSON summary. The --new-machine-readable-summary flag turns on the newer format that arrived in k6 v1.5 and whose schema is published in the grafana/k6-summary repository. The documentation says this format will become the default in 2.x; in 2.4.0 it is still enabled with this flag. The file has config (duration, the script itself), metadata (k6 version, time) and results (checks and metrics). Metrics, including tagged sub-metrics, are a list:

            {
                "contains": "time",
                "name": "http_req_duration{name:POST /api/orders}",
                "type": "trend",
                "values": {
                    "avg": 215.99504358974366,
                    "max": 368.828,
                    "med": 216.693,
                    "min": 111.976,
                    "p90": 295.32169999999996,
                    "p95": 301.80775
                }
            },

Pulling p95 per endpoint with PowerShell is a one-liner:

$s = Get-Content summary.json -Raw | ConvertFrom-Json
$s.results.metrics | Where-Object name -like "http_req_duration{name:*" |
    Select-Object name, @{ n = "p95"; e = { [math]::Round($_.values.p95, 1) } }
name                                              p95
----                                              ---
http_req_duration{name:POST /api/orders}      301.800
http_req_duration{name:GET /api/books/{isbn}}   3.200
http_req_duration{name:GET /api/books?search} 184.000

The file has no threshold results; the exit code tells you whether the test passed. The full text of the script is in the file too; if it contains a secret, take that into account before storing it as an artifact. If you want the summary in a format entirely your own, write a handleSummary() function in the script. At the end of the test k6 calls it with the summary object, and each value you return is written to a file or to stdout.

Production Notes

The third guess question: what happens if k6 runs out of CPU? I sent 4,000 requests per second to book details, which come back from the cache in 0.2 ms. First normally, then with the Go runtime limited to a single core (GOMAXPROCS=1). Nothing changed on the server side:

$env:GOMAXPROCS = "1"
k6 run -e POPULARITY=single -e RATE=4000 -e DURATION=30s book-details.js
k6p50p90p95dropped_iterations
Unlimited134 µs190 µs269 µs503
Single core141 µs911 µs2.49 ms1,209

p95 grew nine times and the number of iterations that could not start more than doubled. The server was the same; what changed was k6's capacity to read responses and send new requests on time. The time k6 measures includes the time a request waits on the client. So watch the CPU of the load-generating machine throughout the test (Task Manager on Windows, or Get-Counter '\Processor(_Total)\% Processor Time'). If it goes above 70-80 percent, do not attribute the result to the server; split the load across several machines (distributed testing in post 04) or cut the expensive work in the script. Parsing large JSON responses for no reason and heavy regexes in checks are the usual suspects.

Keep the complete output of k6 runs: the terminal output, the JSON summary, and which commit, which data and which environment they ran with. The numbers in this post were taken on a single machine with k6 and the API on the same hardware, and their absolute values are machine-specific. An environment note is required to make them comparable with the next run.

Common Pitfalls

  • Thinking a failed check stops the test; what stops it is a threshold with abortOnFail.
  • Using expect() on requests under load: the test is interrupted at the first error and you cannot measure the error rate.
  • Sending dynamic URLs without a name tag: thousands of time series and an unreadable summary.
  • Setting a single general http_req_duration threshold: fast endpoints hide the slow one.
  • Guessing preAllocatedVUs; compute it with Little's Law and put a threshold on dropped_iterations.
  • Averaging the ramp-up and warm-up into the steady-state result.
  • Attributing p95 to the server without watching k6's CPU.
  • Storing the script text in the JSON summary as an artifact while it contains a secret.

A Small Experiment

★☆☆ Run the test with --summary-mode=full and look at the sections printed separately for each scenario and group. Does the search scenario's iteration duration match the 3.5-second assumption in Little's Law?

★★☆ Add a warm-up scenario: it runs the same browse function at a low rate for the first 30 seconds and carries a { phase: 'warmup' } tag. Give the main scenarios startTime: '30s' and { phase: 'main' }, and bind the thresholds only to phase:main.

★★★ Write a handleSummary(): turn p50, p95 and the request count per endpoint into a Markdown table written to summary.md, and write the raw summary to summary.json. That file becomes a pull request comment in post 04.

The test now runs with one command and reports its result as an exit code and JSON. In the next post we put it into the process: a performance gate for every pull request and a nightly test in GitHub Actions, k6's OpenTelemetry output to see test metrics next to application telemetry, distributed tests with the k6 Operator and the browser module.