Testland
Browse all skills & agents

k6-load-testing

Authors k6 JavaScript load-test scripts (VU loops + checks + sleeps), configures the `options` block with `stages` (ramp-up patterns) and `thresholds` (p(95) latency, error rate), runs via `k6 run script.js` or `--vus / --duration` ad-hoc flags, and uses thresholds as the CI pass/fail signal. Use when the project ships HTTP / WebSocket / gRPC load tests and the team wants developer-friendly JavaScript authoring.

k6-load-testing

k6 tests are .js files with a default-exported function that runs once per virtual user (VU) per iteration (per k6-running). This skill covers the load-testing workflow; k6's browser, synthetic monitoring, and chaos modes share the same script structure but are out of scope here.

When to use

  • The project ships HTTP, WebSocket, or gRPC services that need performance testing.
  • The team prefers JavaScript authoring (vs. JMeter's XML, Gatling's Scala/Java, or Locust's Python).
  • A CI gate is needed on p(95) latency + error rate - k6's thresholds are designed for this.
  • The team is on Grafana Cloud (k6 integrates natively) or wants a self-hosted CLI-only flow.

If the team is already deep in JMeter, the migration cost is non- trivial - evaluate jmeter-load-testing in place. For Python / Locust shops, see locust-load-testing. For JVM / Gatling, see gatling-load-testing.

Install

The canonical install methods are documented at k6 installation - brew (macOS), apt / yum (Linux), choco (Windows), Docker. Pin to a specific k6 version in CI for determinism rather than "latest".

Authoring

Minimal script

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

export default function () {
  const res = http.get('https://quickpizza.grafana.com/');
  check(res, { 'status was 200': (r) => r.status == 200 });
  sleep(1);
}

(Per k6-running.)

The default-exported function runs once per virtual-user iteration. check() is a non-failing assertion that contributes to the checks metric; sleep() simulates think-time between requests.

Options block - stages

Per k6-running, the options export controls the run:

export const options = {
  stages: [
    { duration: '30s',    target: 20 },   // ramp up to 20 VUs over 30s
    { duration: '1m30s',  target: 10 },   // hold ~10 VUs for 90s (gradual scale-down)
    { duration: '20s',    target: 0 },    // ramp down to 0
  ],
};

Three canonical shape patterns:

PatternStages
Smoke testOne stage, { duration: '1m', target: 1 } - sanity check.
Average load testRamp up → plateau → ramp down (the example above).
Stress testRamp past expected peak; observe where the system breaks.
Spike testSudden ramp to high VU; back to normal; verify recovery.
Soak testLong plateau (hours); verify no resource leaks over time.

Options block - thresholds

Per k6-thresholds, thresholds are "the pass/fail criteria that you define for your test metrics" - the canonical CI gate:

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95% of requests under 500ms
    http_req_failed:   ['rate<0.01'],   // <1% errors
    checks:            ['rate>0.99'],   // >99% of checks pass
  },
};

(Per k6-thresholds.)

The threshold expression syntax is <aggregation_method> <operator> <value>. Available aggregation methods per metric type:

Metric typeMethods
Trendavg, min, max, med, p(N) (percentile, ms)
Countercount, rate
Raterate
Gaugevalue

A test that fails any threshold exits non-zero - the canonical CI gate signal.

abortOnFail

For long-running stress / soak tests, abort early if a threshold is already violated (k6-thresholds):

thresholds: {
  http_req_duration: [
    { threshold: 'p(95)<500', abortOnFail: true, delayAbortEval: '10s' },
  ],
},

delayAbortEval postpones the abort decision to let metrics accumulate before evaluating; without it, a 1-second test could abort on a single slow request.

Running

Ad-hoc (no options block needed)

k6 run --vus 10 --duration 30s script.js

(Per k6-running.)

--vus overrides the script's options; --duration runs for a fixed time without ramps.

Standard run

k6 run script.js

options block in the script controls everything; the CLI is hands-off. Preferred for CI.

Useful flags

FlagPurpose
--out json=<file>Stream raw metrics to a JSON file.
--summary-export=<file>Write the end-of-test summary to JSON.
--quietSilence the live progress UI (CI noise).
--http-debug=fullVerbose HTTP debug for triage runs.
--env KEY=VALInject env vars accessible via __ENV.KEY.
--config <file>Externalize the options block to a JSON file.

Parsing results

The end-of-run summary prints to stdout; --summary-export writes the same data as JSON for downstream consumption:

{
  "metrics": {
    "http_req_duration": {
      "values": { "p(95)": 432.1, "avg": 198.3, ... },
      "thresholds": { "p(95)<500": { "ok": true } }
    },
    "http_req_failed": {
      "values": { "rate": 0.004, "passes": 996, "fails": 4 },
      "thresholds": { "rate<0.01": { "ok": true } }
    }
  }
}

Pipe to jq for a quick gate report:

jq -r '
  .metrics
  | to_entries[]
  | select(.value.thresholds)
  | .key + ": " + (.value.thresholds | to_entries | map("\(.key) → \(if .value.ok then "PASS" else "FAIL" end)") | join(", "))
' summary.json

CI integration

# .github/workflows/load-test.yml
name: load-test

on:
  pull_request:
    paths:
      - 'tests/load/**'
  schedule:
    - cron: '0 4 * * *'   # nightly soak

jobs:
  k6:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Install k6
        run: |
          sudo gpg -k
          sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
            --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
          echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
          sudo apt-get update
          sudo apt-get install k6

      - name: Run k6 test
        env:
          API_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
          API_TOKEN:    ${{ secrets.STAGING_API_TOKEN }}
        run: |
          k6 run \
            --summary-export=summary.json \
            --quiet \
            tests/load/orders.js

      - name: Upload summary
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-summary
          path: summary.json
          retention-days: 14

A failing threshold causes k6 run to exit non-zero, failing the job; the summary artifact is uploaded regardless via if: always().

Anti-patterns

Anti-patternWhy it failsFix
Hard-coded URLs / tokens in scriptScripts bind to one environment; secrets leak.Read from __ENV.API_BASE_URL, __ENV.API_TOKEN.
--vus 1000 --duration 1h from a developer laptopLaptop resource contention; client-side bottlenecks corrupt metrics.Run from a CI runner / dedicated load generator; never from a dev box for serious numbers.
Threshold p(95)<10000Practically meaningless - passes any sane API. Hides regressions.Set thresholds at meaningful budgets (500ms, 1s) tied to NFRs from the nfr-extractor.
Soak tests in PR CIMulti-hour PR CI; team disables.Soak tests are scheduled-only; PRs run smoke / average load.
Missing sleep() between requestsHammering at full VU rate generates synthetic numbers; doesn't model real users.Include sleep(1) or randomized think-time after each iteration.
Asserting only http_req_failed rateA 30-second response that succeeds passes the rate gate but breaks UX.Always pair with http_req_duration percentile thresholds.

Limitations

  • Single-machine VU limits. A single k6 instance saturates one machine's outbound CPU / network - typically 20-50k concurrent VUs for HTTP. For higher loads, distribute via Grafana Cloud or k6 Operator on Kubernetes.
  • No native browser execution. The k6/browser module covers it but adds Chromium overhead; for browser-driven perf testing, consider lighthouse-perf instead.
  • JS sandbox limits. k6 uses goja (Go-based ES5+/some-ES6), not Node.js. npm packages with native deps don't work; pure-JS packages do. Use k6 archive to bundle.

References