GUIDE / performance
k6 Load Testing Tutorial
k6 load testing tutorial with scripts, stages, thresholds, checks, CI setup, metrics, and browser vs HTTP guidance for practical API performance tests.
This k6 load testing tutorial is a practical path from zero to useful performance coverage. You will install k6, write your first script, add checks and thresholds, model ramping VUs with stages, interpret default metrics, run tests in CI, and understand when to use HTTP scenarios versus browser scenarios. The goal is not a toy hello world. The goal is a maintainable approach you can use on real APIs.
k6 is popular because it is code first, efficient, and friendly to developers and QA alike. Scripts live in Git. Thresholds fail builds. Metrics are clear enough for release decisions when you read them correctly.
k6 Load Testing Tutorial Roadmap
This k6 load testing tutorial follows a deliberate path:
- Install k6 and prove a smoke script runs.
- Learn VUs, stages, and arrival rate executors.
- Add checks for correctness and thresholds for pass fail.
- Build an authenticated multi step API journey.
- Add custom business metrics.
- Wire CI smoke and scheduled load jobs.
- Choose HTTP versus browser testing intentionally.
- Debug failed runs with system correlation.
If you skip straight to high VU counts without checks and thresholds, you will generate traffic without generating decisions. Keep each step small enough that failures are easy to diagnose.
Why Teams Choose k6
Before the commands, understand the fit.
k6 works well when:
- Your primary target is HTTP APIs or microservices.
- You want performance tests as code.
- You need CI friendly pass fail thresholds.
- You want efficient virtual users on modest hardware.
- Developers and QA share scenario ownership.
If you need a broad comparison with JMeter, Gatling, and Locust first, read performance testing tools. Then come back here to implement.
Prerequisites
You need:
- A machine with admin rights to install software, or a container runtime.
- A target API you are allowed to load test.
- Basic JavaScript familiarity.
- An environment that is not production, unless you have explicit approval and guardrails.
Never run open ended load against systems you do not own.
Install k6
macOS
brew install k6
k6 version
Windows
Use the official installer or package manager supported by your team standard. Confirm with:
k6 version
Docker
docker run --rm -i grafana/k6 version
Docker is useful in CI when you do not want to install k6 on every runner image permanently.
Your First k6 Script
Create smoke.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 5,
duration: '30s',
};
export default function () {
const res = http.get('https://test.k6.io');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
Run it:
k6 run smoke.js
What happens:
- k6 starts 5 virtual users.
- Each VU loops the default function for 30 seconds.
- Each iteration sends a GET, checks status, then sleeps one second.
- k6 prints a summary of metrics and check results.
This is a smoke profile. It proves script health more than system capacity.
Understand Virtual Users, Iterations, and Arrival Patterns
k6 can model load in different ways.
VU Duration Mode
export const options = {
vus: 20,
duration: '2m',
};
This keeps 20 concurrent users running as fast as their iterations allow, after think time.
Stages for Ramping VUs
A k6 stages ramping VUs example:
export const options = {
stages: [
{ duration: '2m', target: 50 }, // ramp up
{ duration: '5m', target: 50 }, // hold
{ duration: '1m', target: 0 }, // ramp down
],
};
This is the shape most teams want for a basic load test: warm up, sustain, cool down.
Arrival Rate Mode with Executors
For more precise open model traffic, use scenarios and executors:
export const options = {
scenarios: {
api_load: {
executor: 'ramping-arrival-rate',
startRate: 10,
timeUnit: '1s',
preAllocatedVUs: 50,
maxVUs: 200,
stages: [
{ target: 10, duration: '1m' },
{ target: 50, duration: '3m' },
{ target: 50, duration: '5m' },
{ target: 0, duration: '1m' },
],
},
},
};
Use arrival rate when you care about requests per second more than a fixed user count. Use VUs when you are modeling concurrent sessions with think time.
k6 Thresholds and Checks Tutorial
Checks and thresholds are where k6 becomes a quality gate, not only a traffic generator.
Checks
Checks assert conditions on individual responses and track pass rate:
check(res, {
'status is 200': (r) => r.status === 200,
'body has id': (r) => r.json('id') !== undefined,
'latency under 800ms': (r) => r.timings.duration < 800,
});
Important: a failed check does not fail the test by itself unless you also create a threshold on check rates.
Thresholds
Thresholds define pass fail criteria for the whole run:
export const options = {
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500', 'p(99)<1000'],
checks: ['rate>0.99'],
},
};
Meaning:
- Fewer than 1 percent of requests fail.
- p95 latency under 500 ms.
- p99 latency under 1000 ms.
- More than 99 percent of checks pass.
If thresholds fail, k6 exits non zero, which is perfect for CI.
Recommended Threshold Starter Set
| Metric | Starter threshold | Notes |
|---|---|---|
| http_req_failed | rate under 1 percent | Tighten for critical payments |
| http_req_duration | p95 under SLO | Use business SLO, not vanity numbers |
| checks | rate over 99 percent | Ensures functional correctness under load |
| iteration_duration | p95 budget | Catches slow multi step journeys |
Build a Realistic API Scenario
A useful script models a journey, not only one health endpoint.
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Trend } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';
const loginDuration = new Trend('login_duration');
export const options = {
stages: [
{ duration: '1m', target: 20 },
{ duration: '3m', target: 20 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<700'],
login_duration: ['p(95)<400'],
checks: ['rate>0.99'],
},
};
export function setup() {
// Prepare shared data once if needed
return {
email: __ENV.TEST_EMAIL,
password: __ENV.TEST_PASSWORD,
};
}
export default function (data) {
let token;
group('login', () => {
const res = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
email: data.email,
password: data.password,
}), {
headers: { 'Content-Type': 'application/json' },
});
loginDuration.add(res.timings.duration);
const ok = check(res, {
'login status 200': (r) => r.status === 200,
'token present': (r) => !!r.json('token'),
});
if (!ok) {
return;
}
token = res.json('token');
});
if (!token) {
return;
}
group('list items', () => {
const res = http.get(`${BASE_URL}/items`, {
headers: { Authorization: `Bearer ${token}` },
});
check(res, {
'list status 200': (r) => r.status === 200,
'list is array': (r) => Array.isArray(r.json()),
});
});
sleep(1);
}
Why this is better than a single GET:
- Groups make reports readable.
- Custom trend metric isolates login latency.
- Auth token correlation is realistic.
- Setup keeps secrets and shared prep out of the VU loop when possible.
- Early return avoids cascading nonsense requests after failed login.
Parameterize Environments and Secrets
Never hardcode production credentials.
k6 run \
-e BASE_URL=https://staging-api.example.com \
-e TEST_EMAIL=load.user@example.com \
-e TEST_PASSWORD=secret \
checkout.js
In CI, inject the same variables from your secret store. Keep separate users for load tests so human testers are not locked out or rate limited by accident.
k6 Stages Patterns for Common Test Types
After you learn stages, map them to the right performance intent. For definitions, see load vs stress vs soak vs spike.
Smoke
stages: [
{ duration: '1m', target: 2 },
]
Load
stages: [
{ duration: '5m', target: 100 },
{ duration: '10m', target: 100 },
{ duration: '3m', target: 0 },
]
Spike
stages: [
{ duration: '1m', target: 20 },
{ duration: '30s', target: 300 },
{ duration: '3m', target: 300 },
{ duration: '1m', target: 20 },
]
Soak
stages: [
{ duration: '5m', target: 80 },
{ duration: '2h', target: 80 },
{ duration: '5m', target: 0 },
]
Use different scripts or scenario names so reports stay clear.
What Metrics Does k6 Report by Default?
Important built-ins:
| Metric | Meaning |
|---|---|
| http_reqs | Total HTTP requests |
| http_req_duration | End to end request time |
| http_req_failed | Failed request rate |
| http_req_connecting | TCP connect time |
| http_req_tls_handshaking | TLS handshake time |
| http_req_sending | Time sending request |
| http_req_waiting | TTFB style waiting time |
| http_req_receiving | Time receiving response |
| iterations | Completed default function loops |
| iteration_duration | Full iteration time including sleeps |
| vus | Active virtual users |
| data_received / data_sent | Network volume |
For decision making, focus on:
- Error rate.
- p95 and p99 of http_req_duration.
- Throughput in requests per second.
- Custom business metrics.
- Saturation signals from your APM and infrastructure, not only k6.
Averages hide pain. Prefer percentiles. Learn more in how to read a performance test report.
Custom Metrics That Match Business Risk
import { Counter, Rate, Trend } from 'k6/metrics';
const orderCreated = new Counter('orders_created');
const orderFailRate = new Rate('order_fail_rate');
const checkoutTime = new Trend('checkout_time');
// after a successful order:
orderCreated.add(1);
orderFailRate.add(false);
checkoutTime.add(res.timings.duration);
// after failure:
orderFailRate.add(true);
Then threshold on them:
thresholds: {
order_fail_rate: ['rate<0.005'],
checkout_time: ['p(95)<1200'],
}
Business metrics prevent false confidence from generic HTTP 200 noise.
Think Time, Pacing, and Data
Sleep vs No Sleep
sleep(1) models user think time and reduces unrealistic hammering. For pure API capacity discovery, you may reduce sleep, but document that choice. Unrealistic traffic creates unrealistic bottlenecks.
Test Data
- Prefer unique users or unique order ids when collisions matter.
- Pre-create data in
setup()when creation is expensive. - Clean up in
teardown()if the environment requires it. - Avoid shared mutable records that VUs will fight over.
Correlation
Extract dynamic values from responses:
const id = res.json('data.id');
const next = http.get(`${BASE_URL}/items/${id}`, { headers });
Hardcoded IDs make scripts pass while production paths fail.
Run k6 Tests in CI
A simple GitHub Actions style flow:
name: k6-smoke
on:
pull_request:
jobs:
perf-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run k6 smoke
uses: grafana/k6-action@v0.3.1
with:
filename: tests/perf/smoke.js
env:
BASE_URL: ${{ secrets.STAGING_API_URL }}
TEST_EMAIL: ${{ secrets.LOAD_USER_EMAIL }}
TEST_PASSWORD: ${{ secrets.LOAD_USER_PASSWORD }}
CI design tips:
- Pull requests get short smoke profiles.
- Nightly jobs get fuller load profiles.
- Release candidates get targeted stress or soak where risk is high.
- Store HTML summaries or metric exports as artifacts.
- Fail on thresholds, not on vibes.
For broader pipeline design ideas, see CI/CD for test automation with GitHub Actions. The same discipline applies to performance jobs: deterministic inputs, secrets hygiene, and clear failure signals.
k6 Browser vs HTTP Load Testing
HTTP scenarios answer: can the service handle this request volume with acceptable latency and errors?
Browser scenarios answer: can a real browser complete a user journey with acceptable front end timing?
| Dimension | HTTP load | k6 browser |
|---|---|---|
| Cost per VU | Low | High |
| Max concurrency | High | Limited |
| What it measures | API and protocol performance | Page interactions and browser timing |
| Best use | Capacity, soak, spike on services | Critical UX path checks |
| CI fit | Excellent | Selective |
Practical rule:
- Use HTTP for almost all capacity work.
- Add a few browser checks for key pages if front end performance is a release risk.
- Still measure Core Web Vitals with lab and field tools for SEO and UX. Protocol load alone does not replace that.
Debugging Failed k6 Runs
When a run fails thresholds:
- Confirm the environment was healthy before load.
- Check whether failures are auth, data, or true performance.
- Inspect error bodies for a sample of failures.
- Compare p95 versus max to understand tail behavior.
- Look at dependency metrics: DB, cache, CPU, GC, queue depth.
- Rerun a smaller profile to reproduce.
- Fix or tune, then rerun the same profile for comparison.
Add temporary logging carefully:
if (res.status !== 200) {
console.error(`status=${res.status} body=${String(res.body).slice(0, 200)}`);
}
Do not log secrets or full payloads with personal data.
Project Structure That Scales
tests/
perf/
smoke.js
load-checkout.js
spike-login.js
soak-search.js
lib/
auth.js
items.js
config.js
Keep helpers small. Share auth and config. Avoid a single 2,000 line script that nobody wants to edit.
Common Mistakes in k6 Load Testing
Mistake 1: Only Testing the Health Endpoint
Health can stay green while checkout dies. Script real journeys.
Mistake 2: No Thresholds
A completed run is not a passed run. Encode SLOs.
Mistake 3: Unrealistic Instant Spikes Every Time
Spikes are valid, but not every test should be a spike. Include ramps and sustained load.
Mistake 4: Shared Credentials with Destructive Side Effects
One password change or account lock can invalidate the whole suite. Use dedicated load identities and safe data.
Mistake 5: Ignoring Failed Checks Because Throughput Looks Fine
High RPS with wrong responses is not success. Pair checks with thresholds.
Mistake 6: Running Heavy Load in CI on Shared Staging Without Coordination
You can ruin other teams' test results. Schedule and communicate.
Mistake 7: Treating Browser Tests as Full Capacity Tests
Browsers are expensive. They complement HTTP load. They do not replace it.
Mistake 8: No Baseline Comparison
Save previous results. A p95 of 480 ms means little without last week's 210 ms baseline on the same profile.
Handling Auth, Cookies, and Headers Cleanly
Most real APIs need more than anonymous GET requests.
Bearer Token Pattern
const login = http.post(`${BASE_URL}/auth/login`, payload, params);
const token = login.json('token');
const authHeaders = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
Cookie Jar Awareness
k6 manages cookies per VU by default for the same domain. If your app uses cookie sessions:
- Log in once per VU iteration or once per VU using init patterns carefully.
- Avoid sharing one cookie across all VUs unless that is intentional.
- Clear or rotate sessions when the product requires isolation.
Header Discipline
Centralize headers in helpers so every request sends correlation IDs:
export function apiHeaders(token) {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Request-Id': `k6-${__VU}-${__ITER}`,
};
}
Correlation IDs make server log matching much easier when a threshold fails.
Working with JSON, Status Codes, and Redirects
Parse Carefully
const body = res.json();
check(res, {
'status 200': (r) => r.status === 200,
'has items': () => Array.isArray(body.items),
});
If the body is not JSON, res.json() throws. Guard parsing when endpoints can return HTML error pages under overload.
Redirects
By default k6 follows redirects for HTTP. If you are testing auth redirects or CDN behavior, assert final URL and status explicitly. Do not assume the first hop is the whole story.
Expected Non 200s
Some negative tests intentionally expect 400 or 401. Put those in separate scenarios so they do not inflate http_req_failed in the main happy path profile, or configure failure criteria carefully so expected client errors are not treated as transport failures.
HTML Summary and Result Exports
For local analysis:
k6 run --summary-export=summary.json load-checkout.js
Or generate an HTML report with community or official extensions your team standardizes on. Store artifacts in CI:
- summary JSON
- console logs for failed checks
- link to APM time window of the run
- git SHA of scripts and service under test
Without stored artifacts, weekly debates restart from memory.
Modular Script Design Example
lib/auth.js:
import http from 'k6/http';
import { check } from 'k6';
export function login(baseUrl, email, password) {
const res = http.post(`${baseUrl}/auth/login`, JSON.stringify({ email, password }), {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'login 200': (r) => r.status === 200 });
return res.json('token');
}
load-checkout.js imports login and stays focused on journey orchestration. This structure keeps reviews small and encourages reuse across smoke, load, and spike entry points that only differ in options.
Practical Checklist
Before you call a k6 suite production ready:
- Scripts live in Git with review.
- Environments and secrets are parameterized.
- Smoke, load, and at least one stress or spike profile exist for critical paths.
- Checks validate business correctness.
- Thresholds match agreed SLOs.
- Custom metrics cover key business events.
- CI runs smoke automatically.
- Reports are stored and readable.
- Observability dashboards are watched during major runs.
- Ownership of script maintenance is clear.
- Auth helpers are shared and not copy pasted.
- Correlation IDs are present for debug.
- Negative or destructive data paths are isolated.
Practice Path
- Install k6 and run a 30 second smoke script.
- Convert smoke into a two step authenticated journey.
- Add checks and thresholds.
- Replace fixed VUs with stages.
- Add one custom business metric.
- Run in CI on pull requests as smoke only.
- Schedule a nightly load profile.
- Review results with engineering using percentile language.
- Add one spike profile for a bursty endpoint.
- Document how to read the summary for new teammates.
If you want scenario design practice before scripting, use QABattle sign up and train yourself to turn product risks into explicit checks. Then encode those checks as k6 assertions and thresholds.
Final Guidance
A good k6 load testing tutorial outcome is not that you can start virtual users. It is that you can express realistic journeys, ramp traffic with stages, separate checks from thresholds, read default metrics without fooling yourself, and wire the whole thing into CI so performance regressions are visible early.
Start small. Keep scripts readable. Measure what users and the business feel. Expand from smoke to load, spike, and soak as risk demands. Pair k6 output with system telemetry. Over time, your k6 suite becomes a living performance contract for the services you ship.
FAQ
Questions testers ask
How do you write a k6 load test script?
Write a k6 script as a JavaScript module that imports http, defines export const options for load profile and thresholds, and exports a default function that sends requests, runs checks, and sleeps. Parameterize base URLs and test data with environment variables so the same script runs in local, CI, and staging contexts.
How do you run k6 tests in CI?
Install k6 in the pipeline image or use the official container, check out scripts, inject environment secrets, run k6 with thresholds enabled, and fail the job when thresholds breach. Start with a short smoke profile on pull requests and schedule fuller load profiles nightly against a dedicated environment.
What metrics does k6 report by default?
k6 reports built-in metrics such as http_reqs, http_req_duration, http_req_failed, iteration_duration, vus, and data_received. You can add custom metrics for business events. Use percentiles like p95 and p99 rather than averages when judging latency under load.
What are k6 thresholds and checks?
Checks are per request assertions that record pass rates, such as status is 200. Thresholds are pass fail rules for the whole test, such as http_req_failed rate under 1 percent or p95 under 500 ms. Checks inform quality. Thresholds decide whether the run succeeds.
What is the difference between k6 browser and HTTP load testing?
HTTP load testing measures API and protocol performance with many virtual users efficiently. k6 browser drives real browser interactions to capture front end timing and user path costs. Use HTTP for capacity of services. Use browser tests sparingly for critical journeys because they are heavier.
How do you ramp VUs with k6 stages?
Use options.stages to ramp up, hold, and ramp down virtual users over time. For example, ramp to 50 VUs in 2 minutes, hold for 5 minutes, then ramp down. Stages make load profiles readable and are ideal for smoke, load, and spike shapes in one script.
RELATED GUIDES
Continue the route
Performance Testing Tools: JMeter vs k6 vs Gatling vs Locust
Compare performance testing tools in 2026: JMeter vs k6 vs Gatling vs Locust, open source load testing choices, and picks for APIs and microservices.
Load vs Stress vs Soak vs Spike Testing
Compare load vs stress vs soak vs spike testing with a types chart, when to run each, endurance tips, and practical scenario examples for QA teams.
How to Read a Performance Test Report
Learn how to read a performance test report: interpret p95 and p99, error rates, throughput vs latency graphs, and find bottlenecks from load test results.
CI/CD for Test Automation with GitHub Actions
Learn CI/CD for test automation with GitHub Actions: Playwright workflows, reports, sharding, and PR vs nightly pipeline strategies that scale.