On this page10 sections
CommandsIntermediate8-10 min reference

K6 Load Testing

A practical reference for k6 — Grafana's load testing tool. Scripts run on a Go-based VM that exposes a JavaScript runtime, so most modern JS works (no DOM).

Basic Script Structure

// script.js
import http from 'k6/http';
import { check, sleep } from 'k6';
 
export const options = {
  vus: 10,            // 10 virtual users
  duration: '30s',    // for 30 seconds
};
 
export default function () {
  const res = http.get('https://api.example.com/users');
  check(res, {
    'status is 200': (r) => r.status === 200,
  });
  sleep(1);           // 1 second think-time per VU per iteration
}

Run it:

k6 run script.js

The default function is the VU code — k6 runs it repeatedly per virtual user for the duration.

Init vs VU code

// — Init context (runs once per VU at startup) —
import http from 'k6/http';
const baseUrl = __ENV.BASE_URL ?? 'https://api.example.com';
 
// — VU context (runs per iteration) —
export default function () {
  http.get(`${baseUrl}/health`);
}

You can't make HTTP calls from init code — only file reads, imports, and constant setup belong there.

HTTP Requests

GET / POST / PUT / PATCH / DELETE

http.get('https://api.example.com/users');
 
http.post('https://api.example.com/users',
  JSON.stringify({ name: 'Ada', email: 'ada@example.com' }),
  { headers: { 'Content-Type': 'application/json' } });
 
http.put('https://api.example.com/users/42',  body, params);
http.patch('https://api.example.com/users/42', body, params);
http.del('https://api.example.com/users/42',   null,  params);

Headers and auth

const params = {
  headers: {
    'Content-Type':  'application/json',
    'Authorization': `Bearer ${__ENV.API_TOKEN}`,
    'X-Request-ID':  `${__VU}-${__ITER}`,
  },
  timeout: '15s',
  tags:    { name: 'create-user' },
};
 
http.post(url, JSON.stringify(payload), params);

Reading the response

const res = http.get(url);
 
res.status;                       // 200
res.body;                         // raw text
res.json();                       // parsed JSON
res.json('items.0.name');         // dot-path access
res.headers['Content-Type'];
res.timings.duration;             // total time, ms
res.timings.waiting;              // TTFB
res.timings.connecting;

Form data and file uploads

const fileData = open('./fixtures/users.csv', 'b');   // 'b' = binary
 
http.post('https://api.example.com/import', {
  file: http.file(fileData, 'users.csv', 'text/csv'),
  description: 'Bulk import',
});

Batch (parallel) requests

const responses = http.batch([
  ['GET', 'https://api.example.com/users'],
  ['GET', 'https://api.example.com/orders'],
  ['GET', 'https://api.example.com/products'],
]);
 
check(responses[0], { 'users 200': (r) => r.status === 200 });

Checks (Assertions)

Checks return a boolean per condition — they record pass/fail in the metrics but do not stop the test.

import { check } from 'k6';
 
check(res, {
  'status is 200':            (r) => r.status === 200,
  'body contains success':    (r) => r.body.includes('"status":"ok"'),
  'response time < 500ms':    (r) => r.timings.duration < 500,
  'response is JSON':         (r) => r.headers['Content-Type'].includes('application/json'),
  'JSON has id':              (r) => r.json('id') !== undefined,
});

Tagging checks per group

check(res, { 'login ok': (r) => r.status === 200 }, { feature: 'auth' });

When you actually want to stop

import { fail } from 'k6';
 
const res = http.post('/login', payload);
if (res.status !== 200) {
  fail(`login failed (${res.status})`);
}

Thresholds

Thresholds are pass/fail criteria for the whole run — k6 exits non-zero if any threshold fails. This is what makes k6 usable in CI.

export const options = {
  vus: 50,
  duration: '5m',
  thresholds: {
    // p95 latency under 500ms, p99 under 1s
    http_req_duration: ['p(95)<500', 'p(99)<1000'],
 
    // less than 1% failed requests
    http_req_failed:   ['rate<0.01'],
 
    // total requests above 10000
    http_reqs:         ['count>10000'],
 
    // per-tag thresholds (using request tags)
    'http_req_duration{name:create-user}': ['p(95)<800'],
 
    // multiple criteria with custom abort
    checks: [{
      threshold: 'rate>0.95',
      abortOnFail: true,
      delayAbortEval: '30s',     // grace period before evaluating
    }],
  },
};

Abort on threshold breach

thresholds: {
  http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true }],
}

When the failure rate exceeds 5%, k6 stops the run immediately instead of continuing for the rest of the duration.

Load Patterns (Scenarios)

Scenarios are how you describe non-trivial load shapes. Multiple scenarios can run in the same script.

Constant VUs

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-vus',
      vus: 50,
      duration: '5m',
    },
  },
};

Ramping VUs (the classic load curve)

export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 50 },   // ramp up
        { duration: '5m', target: 50 },   // hold
        { duration: '2m', target: 100 },  // step up
        { duration: '5m', target: 100 },
        { duration: '2m', target: 0 },    // ramp down
      ],
    },
  },
};

Constant arrival rate (target req/sec independent of latency)

export const options = {
  scenarios: {
    target_100rps: {
      executor:        'constant-arrival-rate',
      rate:            100,
      timeUnit:        '1s',
      duration:        '10m',
      preAllocatedVUs: 50,
      maxVUs:          200,
    },
  },
};

ramping-vus ramps users; constant-arrival-rate ramps traffic. The latter is closer to what production sees.

Ramping arrival rate

ramp_traffic: {
  executor:        'ramping-arrival-rate',
  startRate:       10,
  timeUnit:        '1s',
  preAllocatedVUs: 50,
  maxVUs:          500,
  stages: [
    { duration: '2m',  target: 100 },
    { duration: '10m', target: 100 },
    { duration: '2m',  target: 0 },
  ],
}

Iteration-based (no time limit)

shared: {
  executor:   'shared-iterations',
  vus:        10,
  iterations: 1000,        // 1000 total, divided among 10 VUs
}
 
per_vu: {
  executor:   'per-vu-iterations',
  vus:        10,
  iterations: 100,         // 100 per VU = 1000 total, but each VU runs exactly 100
}

Common load profiles

ProfileGoalShape
SmokeVerify the script and SUT respond at all1 VU, 1 minute
LoadValidate behaviour at expected peakramp up → hold for 10–30 min → ramp down
StressFind the breaking pointramp past expected peak until errors / timeouts spike
SpikeValidate behaviour under a burstjump from 10 to 500 VUs in seconds, hold 1 min
SoakFind leaks and degradation over timehold a moderate load for 4–24 h

Groups & Tags

group — organise requests logically

import { group } from 'k6';
 
export default function () {
  group('login flow', () => {
    const res = http.post('/login', creds);
    check(res, { 'logged in': (r) => r.status === 200 });
  });
 
  group('browse catalog', () => {
    http.get('/products');
    http.get('/products/42');
  });
}

Each group surfaces in the summary as its own metric, so you can read latency per business flow.

Tags — attach metadata to metrics

http.get('/orders', { tags: { name: 'list-orders', endpoint: 'orders' } });
http.get('/orders/42', { tags: { name: 'get-order', endpoint: 'orders' } });

Then filter results by tag:

thresholds: {
  'http_req_duration{endpoint:orders}': ['p(95)<400'],
}
k6 run --tag environment=staging script.js     # global tag from CLI

Custom Metrics

import { Counter, Gauge, Rate, Trend } from 'k6/metrics';
 
const errors      = new Counter('app_errors');
const usersOnline = new Gauge('app_users_online');
const cacheHits   = new Rate('app_cache_hit_rate');
const dbTime      = new Trend('app_db_time');
 
export default function () {
  const res = http.get('/dashboard');
 
  errors.add(res.status >= 500 ? 1 : 0);
  cacheHits.add(res.headers['X-Cache'] === 'HIT');
  usersOnline.add(res.json('users_online'));
  dbTime.add(res.timings.duration);
}

Use them in thresholds:

thresholds: {
  app_cache_hit_rate: ['rate>0.8'],
  app_db_time:        ['p(95)<200'],
}

Data & Parameterization

SharedArray — memory-efficient test data

Without SharedArray, every VU gets its own copy of the data — fine for 100 rows, ruinous for 100k.

import { SharedArray } from 'k6/data';
 
const users = new SharedArray('users', () => {
  return JSON.parse(open('./fixtures/users.json'));
});
 
export default function () {
  const user = users[Math.floor(Math.random() * users.length)];
  http.post('/login', JSON.stringify(user));
}

CSV with papaparse

import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
import { SharedArray } from 'k6/data';
 
const users = new SharedArray('users', () =>
  papaparse.parse(open('./fixtures/users.csv'), { header: true }).data
);

Built-in __VU and __ITER

export default function () {
  const i = (__VU - 1) * 1000 + __ITER;     // unique id per VU/iteration
  http.post('/items', JSON.stringify({ ref: `run-${i}` }));
}

Unique data per VU

const users = new SharedArray('users', () => /* ... */);
 
export default function () {
  const user = users[(__VU - 1) % users.length];   // each VU gets its own row
  http.post('/login', JSON.stringify(user));
}

Environment & Configuration

Environment variables

const baseUrl = __ENV.BASE_URL ?? 'https://api.example.com';
const token   = __ENV.API_TOKEN;
k6 run --env BASE_URL=https://staging.example.com \
       --env API_TOKEN=$TOKEN \
       script.js

Config from JSON

k6 run --config config.json script.js

config.json:

{
  "vus": 100,
  "duration": "10m",
  "thresholds": {
    "http_req_duration": ["p(95)<500"]
  }
}

CLI flags override config values; config values override export const options in the script.

Common CLI flags

k6 run --vus 50 --duration 5m script.js
k6 run --iterations 1000 script.js
k6 run --quiet script.js                  # less log noise
k6 run --no-thresholds script.js          # skip threshold evaluation

Output and integrations

# JSON / CSV
k6 run --out json=results.json script.js
k6 run --out csv=results.csv  script.js
 
# InfluxDB (real-time dashboards in Grafana)
k6 run --out influxdb=http://localhost:8086/k6 script.js
 
# Prometheus remote write
K6_PROMETHEUS_RW_SERVER_URL=http://prom:9090/api/v1/write \
  k6 run --out experimental-prometheus-rw script.js
 
# Grafana Cloud k6
k6 cloud script.js

HTML summary report

import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js';
 
export function handleSummary(data) {
  return {
    'summary.html':       htmlReport(data),
    'stdout':             textSummary(data, { indent: ' ', enableColors: true }),
    'summary.json':       JSON.stringify(data),
  };
}

Lifecycle Hooks

import http from 'k6/http';
 
// 1. Init code — runs once per VU at startup
const baseUrl = __ENV.BASE_URL ?? 'https://api.example.com';
 
// 2. setup() — runs ONCE before any VU starts
export function setup() {
  const loginRes = http.post(`${baseUrl}/auth/token`, JSON.stringify({
    client_id: __ENV.CLIENT_ID, client_secret: __ENV.CLIENT_SECRET,
  }), { headers: { 'Content-Type': 'application/json' } });
 
  return { token: loginRes.json('access_token') };
}
 
// 3. default — runs per VU per iteration
export default function (data) {
  http.get(`${baseUrl}/me`, {
    headers: { 'Authorization': `Bearer ${data.token}` },
  });
}
 
// 4. teardown() — runs ONCE after all VUs are done
export function teardown(data) {
  http.post(`${baseUrl}/auth/revoke`, JSON.stringify({ token: data.token }));
}
StageRunsUse for
InitOnce per VU at startupImports, constants, SharedArray setup
setup()Once total, before VUsAuth tokens, seeding test data, cleanup of prior runs
default()Per VU per iterationThe actual load — HTTP calls, checks
teardown()Once total, after VUsRevoke tokens, write summary, clean up DB rows

The return value of setup() is JSON-serialised and passed into both default(data) and teardown(data) — keep it small.