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. Includes a latency-percentile interpretation reference: tail ratio (p99/p50), bimodal-distribution detection, coordinated omission and why naive p99 is optimistic, and constant-vus vs constant-arrival-rate executors. Use when the project ships HTTP / WebSocket / gRPC load tests and the team wants developer-friendly JavaScript authoring, or when a k6 threshold passes but the system still feels slow.
Install with skills.sh (any agent)
npx skills add testland/qa --skill k6-load-testingk6-load-testing
k6 tests are .js files with a default-exported function that runs once per virtual user (VU) per iteration (per k6-running (opens in new window)). 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 and JVM / Gatling teams, see the Gatling and Locust references in load-testing-overview.
Install
Install via k6 installation (opens in new window) (brew, apt/yum, choco, Docker). Pin a specific k6 version in CI 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 (opens in new window).)
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 (opens in new window), 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 (opens in new window), 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 (opens in new window).)
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 (opens in new window)):
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 (opens in new window).)
--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
Full GitHub Actions workflow - apt install, PR + nightly triggers, summary upload via if: always() - in references/ci-integration.md. A failing threshold causes k6 run to exit non-zero, failing the job.
Interpreting latency distributions
Passing a p95 threshold is necessary but not sufficient. The full interpretation workflow - expanding summaryTrendStats to p99/p99.9, the tail ratio (p99/p50) as a spread signal, bimodal-distribution heuristics, sub-metric cross-checks (http_req_waiting / http_req_blocked), coordinated omission and HdrHistogram correction, and constant-vus vs constant-arrival-rate executor semantics - is in references/latency-percentiles.md.
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 non-functional-requirement-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. |
Limitations
References
k6 CI integration
View source (opens in new window)k6 CI integration
A GitHub Actions workflow that installs k6 from the official apt repository, runs the load test on pull requests that touch tests/load/** and on a nightly schedule, and uploads the JSON summary. A failing threshold causes k6 run to exit non-zero and fail the job; the summary artifact is uploaded regardless via if: always().
# .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: 14Pin a specific k6 version in CI for determinism rather than tracking stable.
Interpreting latency percentiles
View source (opens in new window)Interpreting latency percentiles
The interpretation workflow for k6 latency distributions. Passing a p95 threshold is necessary but not sufficient: a system with a bimodal distribution, an inflated tail, or coordinated omission in its measurement can pass every gate while hiding a real user experience problem. Use this when a k6 threshold passes but the system still feels slow, when p99 is suspiciously low during ramp-up, or when the team needs to explain why tail latency is high rather than just observing that it is.
Step 1 - Expand the default percentile set
k6's default summary shows avg, min, med, max, p(90), p(95) per --summary-trend-stats (opens in new window). That range omits p99 and p99.9, where tail pathologies live. Before interpreting anything, expand the output:
k6 run \
--summary-trend-stats="avg,min,med,max,p(50),p(90),p(95),p(99),p(99.9)" \
script.jsOr fix it in the script so every run uses the same stats:
export const options = {
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)'],
};(Per k6-options reference (opens in new window).)
For downstream analysis, export a JSON summary via handleSummary:
export function handleSummary(data) {
return { 'summary.json': JSON.stringify(data) };
}k6 records http_req_duration (send + wait + receive), http_req_waiting (TTFB only), and the sub-phase breakdown in separate Trend metrics per k6 metrics reference (opens in new window). Always pull http_req_waiting alongside http_req_duration: a high p99 on http_req_duration but a normal p99 on http_req_waiting points to response-body transfer or connection-reuse, not server processing.
k6's Trend metric stores all recorded values in a sorted slice and computes percentiles via linear interpolation between neighboring values (verified in github.com/grafana/k6/blob/master/metrics/sink.go). This is accurate but stores every sample in memory; for very long runs with millions of requests, use --out json and post-process with an external histogram library.
Step 2 - Read the distribution shape
Given an expanded summary, apply this reading order:
2a - Check the spread ratio (p99/p50)
Compute the tail ratio: p(99) / p(50).
| Ratio | Signal |
|---|---|
| < 2x | Narrow distribution - system is predictable under this load. |
| 2-5x | Moderate tail - investigate at higher concurrency before signing off. |
| 5-10x | Wide tail - GC pauses, lock contention, or connection pool exhaustion are common causes. |
| > 10x | Bimodal candidate or coordinated omission artifact - see Steps 2b and 3. |
The tail ratio is a single number that summarizes how differently the slow requests behave from the typical ones. A p99 of 800ms with a p50 of 100ms (8x ratio) is a different system than a p99 of 220ms with p50 of 200ms (1.1x ratio), even if both pass p(95)<500.
2b - Check for bimodal shape
A bimodal latency distribution has two peaks: one cluster around the fast path and a second cluster at a much higher value. Common causes include:
Detection heuristics from the summary stats:
If you have access to the raw data (via --out json), plot a histogram with narrow buckets (1ms or 5ms width) to confirm two modes visually before acting on the heuristics.
2c - Cross-check the sub-metrics
Per k6 metrics reference (opens in new window), http_req_duration equals the sum of http_req_sending + http_req_waiting + http_req_receiving.
If p(99) of http_req_duration is high:
Step 3 - Understand coordinated omission
This is the most important concept for interpreting load test p99 values.
What coordinated omission is
In a typical load test, a virtual user sends a request and waits for the response before sending the next one. When the server slows down, the VU slows down with it. The VU and the server are coordinating: during a slow period, fewer requests are issued, so fewer slow samples are recorded.
The result, illustrated in the HdrHistogram README (opens in new window): imagine a server that responds in 1ms for 100 seconds, then pauses for 100 seconds, then resumes. A naive measurement records 10,000 samples at 1ms and 1 sample at 100,000ms. The naive histogram reports ~99.99% of results at or below 1ms. The corrected picture is closer to ~50% at 1ms and 50% distributed across the pause - because every user who arrived during the pause experienced a long wait, not just the one whose request happened to be in-flight.
The same phenomenon applies to VU-based load testing: under a server stall, VUs queue up rather than issuing new requests at the original rate. The requests that complete quickly before and after the stall dilute the tail.
Why p99 lies during ramp-up
During the ramp-up stage, VU count is low and think-time between iterations keeps the server below its saturation point. Samples accumulate at low latencies. When VUs reach plateau, a fraction of requests experience queuing delay, but by that time the histogram already has a large base of fast samples. The p99 computed over the full run can look much better than the p99 computed over the plateau-only window. Always inspect time-windowed summaries (export raw JSON and bucket by timestamp) or use --summary-export only from the plateau phase by separating ramp-up and plateau into distinct scenario stages.
How HdrHistogram corrects for it
HdrHistogram's recordValueWithExpectedInterval(value, expectedInterval) detects when a recorded value exceeds the expected sampling interval and synthesizes intermediate samples to represent the requests that were waiting but never measured (per HdrHistogram README (opens in new window)). The synthesized values are linearly spaced between expectedInterval and the recorded value, filling in the distribution the VU-coordination hides.
k6 does not apply coordinated omission correction by default. Its Trend sink stores raw values. If the test uses sleep() to model think-time and a fixed VU count, the concurrency model naturally prevents one VU from issuing a second request while waiting - so under a server pause, request rate drops. This is the mechanism by which k6 results can understate tail latency under bursty load.
For accurate tail measurement under realistic arrival rates, consider:
Step 4 - Request-rate vs. concurrency models
Understanding which model your test uses changes how you interpret the results.
| Model | k6 executor | How latency is measured |
|---|---|---|
| Concurrency (VU) | constant-vus (default) | VU holds a slot for the duration of the request. Throughput adapts to latency. |
| Request-rate | constant-arrival-rate | k6 issues requests at a fixed rate. If VUs run out, k6 reports dropped_iterations. |
With constant-vus, a high p99 might be masking the fact that throughput also dropped during those slow periods. The system's capacity degraded; the histogram only shows that some requests were slow, not that many were never sent.
With constant-arrival-rate, slow requests cause VU starvation. Watch dropped_iterations alongside percentiles. If dropped_iterations > 0, the p99 you see is from the requests that did complete - it excludes the dropped ones which represent an infinite-latency from the user's perspective.
A concrete read-back pattern using the summary.json export:
jq '{
p50: .metrics.http_req_duration.values["p(50)"],
p95: .metrics.http_req_duration.values["p(95)"],
p99: .metrics.http_req_duration.values["p(99)"],
tail_ratio: (.metrics.http_req_duration.values["p(99)"] /
.metrics.http_req_duration.values["p(50)"]),
dropped: .metrics.dropped_iterations.values.count
}' summary.jsonA non-null dropped count combined with a low tail ratio is a red flag: the fast percentiles are artificially low because the slow requests were never issued.
Step 5 - Thresholds to gate on
Per k6 thresholds (opens in new window), set thresholds on the metrics that surface the patterns above:
export const options = {
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)'],
thresholds: {
// Gate on p99 as well as p95 - the tail matters.
http_req_duration: ['p(95)<500', 'p(99)<1500'],
// TTFB gate catches server-side slowness independently of payload size.
http_req_waiting: ['p(99)<1000'],
// A blocked p99 > 50ms means connection pool exhaustion at the VU layer.
http_req_blocked: ['p(99)<50'],
http_req_failed: ['rate<0.01'],
// Gate on dropped iterations when using constant-arrival-rate.
dropped_iterations: ['count<10'],
},
};The http_req_blocked threshold catches a pathology that p95/p99 on http_req_duration can obscure: requests that spend the majority of their time waiting for a free socket at the client, not at the server.
Anti-patterns
| Anti-pattern | Why it misleads | Fix |
|---|---|---|
Reporting only p(95) in the summary | The 1-in-20 slowest requests are invisible. A p95 of 400ms with a p99 of 4000ms looks healthy. | Add p(99) and p(99.9) via summaryTrendStats. |
| Averaging across ramp-up and plateau | Low-load ramp-up samples dilute plateau tail. | Use separate scenarios or post-filter the JSON export by timestamp. |
Ignoring dropped_iterations | Under constant-arrival-rate, unreported requests make p99 look better than reality. | Always include dropped_iterations in the summary export check. |
| Treating avg as representative | A bimodal distribution has no typical request; avg falls between the two modes. | Use med (p50) as the central tendency; use tail ratio to confirm shape. |
| Comparing p99 across different VU counts | Higher concurrency changes the distribution; the numbers are not comparable. | Normalize by http_reqs rate (RPS) and note the executor type alongside any p99 number. |
References
Related skills
db-query-plan-analyzer
Reads `EXPLAIN` / `EXPLAIN ANALYZE` output from PostgreSQL, MySQL, or SQLite - identifies the dominant cost (sequential scan, nested loop, sort spill, missing index, type-cast preventing index use), proposes the specific index or query rewrite to fix it, and emits the candidate `CREATE INDEX` statement. Use when load testing or production telemetry shows the database as the bottleneck and the team needs targeted query-level remediation.
flame-graph-analyzer
Reads CPU flame-graph output from py-spy (Python), async-profiler (JVM), Go pprof, or Node.js `perf_hooks` / clinic.js: identifies the hot path (top sample-time frames), classifies the bottleneck (CPU-bound vs lock contention vs allocator pressure), and proposes the next investigation step. Use when a perf regression is bisected to a commit but the hot path inside it is unclear; for tail-latency percentiles use the latency-percentiles reference in k6-load-testing, and for a slow SQL hot path use db-query-plan-analyzer.
jmeter-load-testing
Authors Apache JMeter `.jmx` test plans (Thread Groups + HTTP samplers + assertions + listeners) in the JMeter GUI, runs them headlessly via `jmeter -n -t plan.jmx -l results.jtl`, generates an HTML dashboard with `-e -o`, and gates CI on JTL parsing. Use when the project has an existing JMeter investment, needs JVM-native load tooling, or works in domains with strong JMeter community support (banking, telecom, enterprise).
lighthouse-perf
Configures Lighthouse CI (`@lhci/cli`) to audit Web Vitals (LCP, INP, CLS) on every PR, asserts against canonical thresholds (LCP ≤2.5s, INP ≤200ms, CLS ≤0.1 at the 75th percentile), uploads Lighthouse reports as build artifacts, and posts deltas as PR comments. Includes a budget-authoring reference: per-route LCP/INP/CLS thresholds by traffic class (cached / dynamic / api-heavy / form-heavy / media-heavy) via `assertMatrix`, plus `budget.json` resource-size caps (JS / CSS / images / total bytes). Use when the project ships a web frontend and the team needs continuous Web Vitals monitoring tied to PR gating, or needs its first Lighthouse budgets drafted.
load-testing-overview
Teaches load and performance testing from zero: a tool-selection table choosing between k6, JMeter, Gatling, Locust, and Artillery from observable project facts; the six load profiles (smoke, average-load, stress, spike, soak, breakpoint); open vs closed workload models; why percentiles beat averages; turning a run into a pass/fail CI gate with a first runnable k6 script; a performance-incident triage workflow (confirm with a k6 smoke run, flame-graph the hot path, check slow queries, localize the cause); and full Gatling (Simulation DSL, injectOpen/injectClosed, setUp().assertions()) and Locust (HttpUser + @task locustfile, headless / distributed runs, CSV gating) deep dives in references. Use when a service needs performance coverage and the tool, load profile, or pass/fail threshold has not been decided yet, or when a live performance incident needs cause localization.
perf-budget-gate
Builds a unified release-readiness gate that aggregates verdicts from any combination of k6 / JMeter / Gatling / Locust load runners and Lighthouse CI Web Vitals, applies severity-aware pass/fail thresholds, and emits a single go / no-go decision with per-metric deltas vs the main-branch baseline. Posts the delta as a PR comment when the team has the integration set up. Use when authoring a CI step that gates a deployment on cross-runner perf compatibility.
slo-load-test-plan
Turns a service's SLOs and endpoint traffic mix into a named scenario matrix: one scenario per SLO boundary condition, a load profile (smoke, average-load, stress, soak, spike, breakpoint) per scenario, an open or closed workload injection model, a threshold expression derived from the SLO the scenario guards, and an error-budget calculation that sets the soak run's failure allowance. Stays runner-agnostic and fixes the pass/fail line before any tool is configured. Use when an SLO document and an endpoint list both exist but nobody has decided which load runs to make, what shape of load each carries, or what number would count as a failure.
web-vitals-inp-deep
Deep INP (Interaction to Next Paint) testing: decomposes input delay, processing duration, and presentation delay via the web-vitals/attribution build, asserts per-interaction INP budgets in Playwright using PerformanceObserver plus the web-vitals visibilitychange flush, and identifies long tasks blocking the main thread. Use when a page feels unresponsive while LCP and CLS are green, or to gate key interactions (form submit, modal open, route change) under an INP budget in CI. Covers interactions only - for page-load Web Vitals gating use lighthouse-perf; for service-worker cache-strategy latency use the qa-pwa plugin's service-worker skills.