Load testing as code. k6 lets you write performance tests in JavaScript, run thousands of virtual users from a single lightweight binary, and gate releases on hard thresholds tied to your SLOs. You'll install k6, write your first test, shape realistic load profiles, assert on latency and error budgets, parameterize data, and wire it into CI. Hands-on throughout.
The journey:
| You need | Why |
|---|---|
The k6 binary |
Runs the tests (no runtime/deps to install) |
| A target to test | An HTTP API or web app you're allowed to load |
| Basic JavaScript | Tests are ES modules |
⚠️ Only load-test systems you own or have written permission to test. A stress test is indistinguishable from a denial-of-service attack. Never point k6 at third-party production without authorization.
Functional tests answer "does it work?". Load tests answer "does it still work at 500 requests/second, and how does it fail?". You're hunting for the point where latency spikes, error rates climb, or a resource saturates — before your users find it.
k6 stands out because:
thresholds give a clean exit code to gate a pipeline.The four questions a load test answers: How fast? How many? How does it degrade? When does it break?
# macOS
brew install k6
# Debian/Ubuntu
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
# Docker
docker run --rm -i grafana/k6 run - <script.js
Confirm it works:
k6 version
Every k6 script exports a default function — the code each virtual user (VU) runs, over and over, for the duration of the test.
// script.js
import http from 'k6/http'
import { sleep } from 'k6'
export default function () {
http.get('https://test.k6.io')
sleep(1) // think time — don't hammer without pause
}
Run it:
k6 run script.js
You get a summary: request count, requests/second, and the all-important http_req_duration (with avg, min, med, p90, p95, max). That p95 is usually what your SLO is written against.
A VU is a concurrent, parallel user looping your default function. Control the load with options:
export const options = {
vus: 10, // 10 concurrent virtual users
duration: '30s', // for 30 seconds
}
Or run a fixed number of iterations instead of a duration:
export const options = { vus: 5, iterations: 100 } // 100 total iterations, shared across 5 VUs
Flags override options, which is handy for quick sweeps: k6 run --vus 50 --duration 1m script.js.
💡 VUs ≠ requests/second. Throughput depends on how long each iteration takes. 10 VUs where each iteration takes 200 ms yields ~50 req/s; if the server slows down, throughput drops even though VUs stay fixed. To pin a request rate, use the
constant-arrival-rateexecutor (Part 7).
A check validates a response. Unlike a failed assertion in unit tests, a failed check does not stop the test — it's recorded as a rate you can inspect and threshold on. That's what you want under load: keep going, measure the failure rate.
import http from 'k6/http'
import { check } from 'k6'
export default function () {
const res = http.get('https://test.k6.io')
check(res, {
'status is 200': (r) => r.status === 200,
'body has title': (r) => r.body.includes('Collection'),
'fast enough (<500ms)': (r) => r.timings.duration < 500,
})
}
The summary shows the pass rate for each check. Group related requests with group() to organize the output by user journey.
Thresholds are the feature that makes k6 a CI gate. If a threshold is breached, k6 run exits non-zero and your pipeline fails. Express your SLOs directly:
export const options = {
vus: 20,
duration: '1m',
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95% under 500ms, 99% under 1s
http_req_failed: ['rate<0.01'], // error rate under 1%
checks: ['rate>0.99'], // 99%+ of checks pass
},
}
You can also abort early to save time when things are clearly broken:
thresholds: {
http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '10s' }],
}
Real tests ramp load rather than slamming it on. Use stages (or the richer scenarios/executors) to shape the profile.
export const options = {
stages: [
{ duration: '2m', target: 100 }, // ramp up to 100 VUs
{ duration: '5m', target: 100 }, // hold (steady state)
{ duration: '2m', target: 0 }, // ramp down
],
}
The standard test types, each answering a different question:
| Type | Shape | Question it answers |
|---|---|---|
| Smoke | 1–5 VUs, short | Does the script work at all? Baseline. |
| Load | Ramp to expected peak, hold | Does it meet SLOs at normal peak? |
| Stress | Ramp past peak | Where does it break, and how? |
| Spike | Sudden jump to very high | Does it survive a flash crowd and recover? |
| Soak | Moderate load for hours | Memory leaks, resource exhaustion over time? |
To hold a request rate regardless of latency, use an arrival-rate executor:
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate',
rate: 200, timeUnit: '1s', // 200 iterations/sec
duration: '5m',
preAllocatedVUs: 50, maxVUs: 200,
},
},
}
k6 collects built-in metrics automatically. The ones you'll live in:
| Metric | Meaning |
|---|---|
http_req_duration |
Total request time (the latency SLO metric) |
http_req_failed |
Rate of failed requests |
http_reqs |
Total requests + throughput (req/s) |
iterations |
Completed loops of your default function |
vus / vus_max |
Active / allocated virtual users |
Add custom metrics to measure business-level things:
import { Trend, Counter, Rate } from 'k6/metrics'
const loginTime = new Trend('login_time', true)
const errors = new Rate('business_errors')
export default function () {
const res = http.post('https://api.example.com/login', /* … */)
loginTime.add(res.timings.duration)
errors.add(res.status !== 200)
}
Trend (timings/distributions), Counter (running totals), Rate (percentage true), and Gauge (last value) cover almost everything.
Hitting the same URL with the same body isn't realistic (and may just test your cache). Load data once and share it across all VUs with SharedArray — it parses the file a single time instead of once per VU:
import { SharedArray } from 'k6/data'
import http from 'k6/http'
const users = new SharedArray('users', () => JSON.parse(open('./users.json')))
export default function () {
const u = users[Math.floor(Math.random() * users.length)]
http.post('https://api.example.com/login', JSON.stringify(u), {
headers: { 'Content-Type': 'application/json' },
})
}
Pass secrets and config via environment variables, never hard-coded: k6 run -e BASE_URL=https://staging.example.com script.js, read with __ENV.BASE_URL.
Because a breached threshold returns a non-zero exit code, wiring k6 into a pipeline is trivial:
# .github/workflows/load-test.yml
name: Load test
on: [workflow_dispatch]
jobs:
k6:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- run: k6 run --quiet tests/load.js
env:
BASE_URL: ${{ secrets.STAGING_URL }}
Run smoke tests on every PR (fast, catches broken scripts and gross regressions); run the heavier load/stress/soak profiles on a schedule or manual trigger against a staging environment that mirrors production.
sleep) to model real users; back-to-back requests over-estimate load and under-estimate concurrency.| Mistake | Fix |
|---|---|
| Confusing VUs with req/s | Use an arrival-rate executor to fix throughput |
| No think time | Add sleep() — model real behavior |
| Parsing data per VU | Use SharedArray to parse once |
Ignoring http_req_failed |
A fast p95 over 30% errors is meaningless |
| Load-testing through a CDN cache | Bust the cache or hit the origin to test the app |
| No thresholds | Add them — that's how CI knows pass from fail |
p(95)<800 threshold.http_req_failed first crosses 1%.SharedArray of users and randomize each iteration's login payload.Trend for a checkout endpoint and threshold its p95 separately from the global one.You can now write realistic load tests as code, express SLOs as enforceable thresholds, shape smoke/load/stress/spike/soak profiles, and gate releases on performance in CI. Pair this with the Prometheus and Grafana tutorials to watch the server side while k6 drives the load.