TL;DR — Quick Summary
Master Grafana k6 for load and performance testing. Virtual users, thresholds, scenarios, CI/CD integration, browser testing, and Grafana dashboards.
Grafana k6 is a developer-first load testing tool that runs JavaScript test scripts on a high-performance Go runtime, giving you code-as-test reproducibility, version control, and seamless CI/CD integration. This guide covers everything from your first smoke test to production-grade scenarios with custom metrics, browser testing, and Grafana dashboards.
Prerequisites
- Node.js not required — k6 has its own embedded JavaScript runtime (Goja, a Go-based ES6+ engine).
- k6 installed — Homebrew, apt, Docker, or binary release.
- Basic JavaScript knowledge — functions, objects, loops.
- A target service — any HTTP API, WebSocket server, or gRPC endpoint.
k6 Architecture
k6 runs JavaScript test scripts inside a Go runtime. Each virtual user (VU) is a lightweight goroutine executing your default function in a loop. Key concepts:
- VUs — Concurrent virtual users. Each runs your script independently.
- Iterations — One execution of the
defaultfunction by one VU. - Checks — Assertions (like
status === 200). Failing checks do NOT stop the test. - Thresholds — Pass/fail criteria (like
p(95) < 250). Breaching one exits k6 with code 99. - Metrics — Built-in (
http_req_duration,http_reqs,vus) and custom (Trend,Counter,Gauge,Rate).
Built-in protocols:
| Protocol | Module | Notes |
|---|---|---|
| HTTP/1.1 & HTTP/2 | k6/http | Automatic HTTP/2 negotiation |
| WebSocket | k6/experimental/websockets | Full WS frame support |
| gRPC | k6/net/grpc | Unary and streaming |
| Redis | k6/experimental/redis | Via xk6-redis extension |
| Browser | k6/browser | Real Chromium via Playwright |
Installation
# macOS (Homebrew)
brew install k6
# Ubuntu / Debian
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 update && sudo apt install k6
# Docker
docker pull grafana/k6
# Run via Docker
docker run --rm -i grafana/k6 run - < script.js
xk6 extensions let you build a custom k6 binary with extra protocols:
go install go.k6.io/xk6/cmd/xk6@latest
xk6 build --with github.com/grafana/xk6-redis
Writing Tests in JavaScript
Basic HTTP test
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';
// Custom metrics
const apiLatency = new Trend('api_latency', true); // true = display as ms
const apiErrors = new Rate('api_errors');
const apiCalls = new Counter('api_calls');
export const options = {
stages: [
{ duration: '30s', target: 20 }, // ramp up
{ duration: '1m', target: 20 }, // plateau
{ duration: '10s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<250'], // 95th percentile < 250ms
http_req_failed: ['rate<0.01'], // error rate < 1%
api_latency: ['p(99)<500'], // custom metric threshold
},
};
export default function () {
group('User API', () => {
// GET request
const listRes = http.get(`${__ENV.BASE_URL}/api/users`, {
headers: { 'Authorization': `Bearer ${__ENV.API_TOKEN}` },
});
check(listRes, {
'list status 200': (r) => r.status === 200,
'list returns array': (r) => Array.isArray(r.json()),
});
apiLatency.add(listRes.timings.duration);
apiErrors.add(listRes.status !== 200);
apiCalls.add(1);
// POST request
const createRes = http.post(
`${__ENV.BASE_URL}/api/users`,
JSON.stringify({ name: 'k6 user', email: `k6-${Date.now()}@test.com` }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(createRes, { 'create status 201': (r) => r.status === 201 });
// Extract and reuse value (correlation)
const userId = createRes.json('id');
// DELETE
const delRes = http.del(`${__ENV.BASE_URL}/api/users/${userId}`);
check(delRes, { 'delete status 204': (r) => r.status === 204 });
});
sleep(1); // pacing — think time between iterations
}
Run with environment variables:
k6 run -e BASE_URL=https://api.example.com -e API_TOKEN=secret script.js
Load Test Types
Smoke test — verify script works
export const options = { vus: 1, duration: '30s' };
Load test — normal expected traffic
export const options = {
stages: [
{ duration: '2m', target: 100 }, // ramp up to 100 VUs
{ duration: '5m', target: 100 }, // hold
{ duration: '2m', target: 0 }, // ramp down
],
};
Stress test — beyond normal capacity
export const options = {
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 300 }, // push beyond normal
{ duration: '5m', target: 300 },
{ duration: '2m', target: 0 },
],
};
Spike test — sudden surge
export const options = {
stages: [
{ duration: '10s', target: 1000 }, // instant spike
{ duration: '1m', target: 1000 },
{ duration: '10s', target: 0 }, // instant drop
],
};
Soak test — extended duration (find memory leaks)
export const options = {
stages: [
{ duration: '5m', target: 100 },
{ duration: '8h', target: 100 }, // 8-hour soak
{ duration: '5m', target: 0 },
],
};
Breakpoint test — find the limit
export const options = {
stages: [
{ duration: '2h', target: 20000 }, // slowly ramp to extreme load
],
thresholds: {
http_req_failed: [{ threshold: 'rate<0.01', abortOnFail: true }],
},
};
The abortOnFail: true flag stops the test the moment the error rate breaches 1%, recording the VU count at the breaking point.
Scenarios and Executors
Scenarios give fine-grained control over how VUs are scheduled:
export const options = {
scenarios: {
// Fixed arrival rate — 100 requests per second regardless of VU count
constant_load: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50,
maxVUs: 200,
},
// Separate scenario for a different endpoint
browse_api: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 50 },
{ duration: '3m', target: 50 },
{ duration: '1m', target: 0 },
],
exec: 'browseScenario', // different function
},
},
};
export function browseScenario() {
http.get(`${__ENV.BASE_URL}/browse`);
sleep(2);
}
Executor reference:
| Executor | Use case |
|---|---|
shared-iterations | Fixed total iterations shared across VUs |
per-vu-iterations | Each VU completes N iterations |
constant-vus | Fixed VU count for a duration |
ramping-vus | Stages-based VU ramp |
constant-arrival-rate | Fixed RPS / TPS regardless of VU count |
ramping-arrival-rate | Variable RPS over stages |
externally-controlled | REST API controls VU count at runtime |
Data Parameterization
import { SharedArray } from 'k6/data';
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
// SharedArray loads data once, shares across all VUs (memory efficient)
const users = new SharedArray('users', function () {
return papaparse.parse(open('./test-users.csv'), { header: true }).data;
});
export default function () {
const user = users[__VU % users.length]; // round-robin per VU
http.post('/login', JSON.stringify({ email: user.email, password: user.password }));
sleep(1);
}
Setup and Teardown
export function setup() {
// Runs once before all VUs start — create test data, get auth token
const res = http.post('/auth/token', JSON.stringify({ client: 'k6' }));
return { token: res.json('access_token') }; // returned value passed to default()
}
export default function (data) {
http.get('/api/protected', { headers: { Authorization: `Bearer ${data.token}` } });
sleep(1);
}
export function teardown(data) {
// Runs once after all VUs finish — clean up test data
http.del(`/auth/token/${data.token}`);
}
Output and Visualization
# JSON output
k6 run --out json=results.json script.js
# CSV output
k6 run --out csv=results.csv script.js
# InfluxDB (for Grafana)
k6 run --out influxdb=http://localhost:8086/k6 script.js
# Prometheus remote write
k6 run --out experimental-prometheus-rw script.js
InfluxDB + Grafana setup:
docker network create k6
docker run -d --name influxdb --network k6 \
-p 8086:8086 \
-e INFLUXDB_DB=k6 \
influxdb:1.8
docker run -d --name grafana --network k6 \
-p 3000:3000 \
grafana/grafana
# Import Grafana dashboard ID 2587 (official k6 dashboard)
# Add InfluxDB data source: http://influxdb:8086, database: k6
Run the test against the containerized stack:
docker run --rm -i --network k6 \
-e BASE_URL=http://my-app:8080 \
grafana/k6 run --out influxdb=http://influxdb:8086/k6 - < script.js
CI/CD Integration
GitHub Actions
name: Load Test
on:
push:
branches: [main]
jobs:
k6-load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run k6 load test
uses: grafana/k6-action@v0.3.1
with:
filename: tests/load/api.js
flags: --vus 50 --duration 2m
env:
BASE_URL: ${{ secrets.STAGING_URL }}
API_TOKEN: ${{ secrets.API_TOKEN }}
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: k6-results
path: results.json
k6 exits with code 99 on threshold breach, causing the Actions step to fail automatically — no extra logic needed.
GitLab CI
load-test:
image: grafana/k6
stage: test
script:
- k6 run --vus 20 --duration 1m tests/load/api.js
variables:
BASE_URL: $STAGING_URL
artifacts:
when: always
paths: [results.json]
Browser Testing with k6
import { browser } from 'k6/browser';
import { check } from 'k6';
export const options = {
scenarios: {
ui: {
executor: 'shared-iterations',
options: { browser: { type: 'chromium' } },
},
},
};
export default async function () {
const page = await browser.newPage();
try {
await page.goto('https://example.com');
// Interact with the page
await page.locator('input[name="email"]').fill('user@test.com');
await page.locator('button[type="submit"]').click();
await page.waitForNavigation();
check(page, {
'dashboard visible': (p) => p.locator('h1').textContent() === 'Dashboard',
});
// Collect Web Vitals (LCP, FID, CLS)
const metrics = await page.evaluate(() => ({
lcp: window.__webVitals?.lcp,
cls: window.__webVitals?.cls,
}));
console.log('Web Vitals:', JSON.stringify(metrics));
} finally {
await page.close();
}
}
k6 vs Other Load Testing Tools
| Tool | Language | Protocol support | VU model | CI/CD | Learning curve |
|---|---|---|---|---|---|
| k6 | JavaScript | HTTP, WS, gRPC, Browser | Goroutine | Native | Low |
| JMeter | XML/GUI | HTTP, JMS, FTP, JDBC | Thread | Plugin | High |
| Gatling | Scala/DSL | HTTP, WS | Async actor | Maven/Gradle | Medium |
| Locust | Python | HTTP, custom | Async coroutine | CLI | Low |
| Artillery | YAML/JS | HTTP, WS, Socket.io | Node.js | Native | Low |
| wrk | Lua | HTTP only | Epoll | Manual | Very low |
| hey | None | HTTP only | Goroutine | Manual | Very low |
When to choose k6: developer teams who want code-as-test, CI/CD-native thresholds, protocol diversity, and direct Grafana visualization without managing a separate test cluster.
Gotchas and Edge Cases
- k6 is NOT Node.js. You cannot use
require(),fs, or most npm packages. Use the built-inopen()for local files and jslib.k6.io for shared utilities. - Checks are not assertions. A failed check does not stop the test or fail the threshold by itself — only threshold breaches do. Wire check results to a
Ratemetric if you want threshold-enforced check pass rates. - sleep() is mandatory for realistic load. Without
sleep(), VUs hammer the target as fast as possible, simulating a denial-of-service, not real user traffic. - SharedArray vs open(). For large CSV/JSON datasets, use
SharedArray— it parses once and shares memory across all VUs. Usingopen()indefault()parses the file for every single iteration. - HTTP/2 push. k6 receives server-pushed resources but does not process them — they are counted in the connection but not as separate requests.
- TLS certificate errors. Use
k6 run --insecure-skip-tls-verifyfor self-signed certs in staging. Never use this flag in production testing.
Troubleshooting
| Problem | Solution |
|---|---|
ERRO[...] GoError: dial tcp: connection refused | Target service is not running or wrong BASE_URL |
| Thresholds pass locally but fail in CI | CI runner CPU limits affect response times; use --vus appropriate to CI resources |
WARN too many open files | Raise ulimit: ulimit -n 65535 before running k6 |
| InfluxDB dashboard shows no data | Verify InfluxDB is reachable; check --out influxdb= URL matches container name |
Browser tests fail with context canceled | Add await page.waitForNavigation() after click; browser tests need async/await |
| k6 exits 0 despite errors | Checks failing does NOT cause non-zero exit — add a Rate threshold on error rate |
Summary
- k6 runs JavaScript on a Go runtime — lightweight VUs, code-as-test, Git-friendly.
- Test types — smoke, load, stress, spike, soak, breakpoint — each targeting a different risk.
- Thresholds enforce pass/fail in CI;
abortOnFailstops runaway breakpoint tests. - Scenarios with executors separate arrival-rate control from VU count management.
- SharedArray parameterizes large datasets without per-VU memory overhead.
- Grafana dashboard 2587 visualizes real-time results from InfluxDB output.
- k6 browser module measures Web Vitals alongside API performance in the same test run.