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
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:
| Pattern | Stages |
|---|---|
| Smoke test | One stage, { duration: '1m', target: 1 } - sanity check. |
| Average load test | Ramp up → plateau → ramp down (the example above). |
| Stress test | Ramp past expected peak; observe where the system breaks. |
| Spike test | Sudden ramp to high VU; back to normal; verify recovery. |
| Soak test | Long 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 type | Methods |
|---|---|
| Trend | avg, min, max, med, p(N) (percentile, ms) |
| Counter | count, rate |
| Rate | rate |
| Gauge | value |
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.jsoptions block in the script controls everything; the CLI is hands-off. Preferred for CI.
Useful flags
| Flag | Purpose |
|---|---|
--out json=<file> | Stream raw metrics to a JSON file. |
--summary-export=<file> | Write the end-of-test summary to JSON. |
--quiet | Silence the live progress UI (CI noise). |
--http-debug=full | Verbose HTTP debug for triage runs. |
--env KEY=VAL | Inject 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.jsonCI 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: 14A failing threshold causes k6 run to exit non-zero, failing the job; the summary artifact is uploaded regardless via if: always().
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Hard-coded URLs / tokens in script | Scripts bind to one environment; secrets leak. | Read from __ENV.API_BASE_URL, __ENV.API_TOKEN. |
--vus 1000 --duration 1h from a developer laptop | Laptop 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)<10000 | Practically 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 CI | Multi-hour PR CI; team disables. | Soak tests are scheduled-only; PRs run smoke / average load. |
Missing sleep() between requests | Hammering 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 rate | A 30-second response that succeeds passes the rate gate but breaks UX. | Always pair with http_req_duration percentile thresholds. |