K6

k6 is a Go binary that runs your test script in an embedded JavaScript runtime (Sobek — not Node). Concurrency is handled by Go goroutines; the JS is just the description of what one virtual user does. Every gotcha in k6 traces back to that split.
Author

Benedict Thekkel

Script lifecycle — the thing to internalise first

// 1. init — runs once per VU, on every VU. No HTTP allowed here.
import http from 'k6/http';
const payload = JSON.parse(open('./data.json'));

export const options = { /* 2. config, read once from the first init */ };

export function setup() {
  // 3. runs ONCE for the whole test. Return value must be JSON-serialisable.
  const res = http.post(`${BASE}/api/auth/token/`, creds);
  return { token: res.json('access') };
}

export default function (data) {
  // 4. the VU loop. Runs over and over, per VU, for the test duration.
  http.get(`${BASE}/api/patients/`, { headers: { Authorization: `Bearer ${data.token}` } });
}

export function teardown(data) { /* 5. once, at the end */ }

Init code runs once per VU, not once per test. 1000 VUs × a 50MB open() = 50GB. That’s the single most common way people OOM their load generator. Use SharedArray:

import { SharedArray } from 'k6/data';
const patients = new SharedArray('patients', () => JSON.parse(open('./patients.json')));

Each VU is a separate JS runtime with its own globals. No shared mutable state between VUs — coordinate via setup()’s return value or an external service.

Executors

The most important configuration decision in k6.

Executor Controls Use for
shared-iterations N iterations split across VUs Fixed work, e.g. process 10k records
per-vu-iterations N iterations each Deterministic per-user journeys
constant-vus Fixed concurrency Simulating N connected clients
ramping-vus VU count over stages Legacy; mostly the wrong choice
constant-arrival-rate Fixed req/s Load tests against an SLO
ramping-arrival-rate req/s over stages Stress tests, finding the knee
externally-controlled Live via REST API Manual exploration

Use arrival-rate executors by default. VU-based executors reduce offered load when your server slows down, which hides the failure mode you’re testing for (coordinated omission). Arrival-rate keeps the pressure on and reports dropped_iterations when it can’t keep up — that metric is your signal that maxVUs was too low.

export const options = {
  scenarios: {
    api: {
      executor: 'ramping-arrival-rate',
      startRate: 10, timeUnit: '1s',
      preAllocatedVUs: 100, maxVUs: 2000,
      stages: [{ target: 500, duration: '10m' }],
    },
  },
};

Scenarios can run in parallel, use different exec functions, and be offset with startTime.

Metrics

Four types: Counter, Gauge, Rate, Trend. Everything built-in is one of these.

Key built-ins: - http_req_duration = sending + waiting + receiving. Excludes DNS/TCP/TLS. - http_req_waiting — TTFB. Usually the number that reflects server work. - http_req_blocked — includes connection-pool wait and DNS. High values here often mean your generator is the bottleneck. - iteration_duration, dropped_iterations, vus, vus_max, data_sent/data_received, checks

Custom metrics:

import { Trend, Counter } from 'k6/metrics';
const dbHeavyLatency = new Trend('db_heavy_latency', true);
const authFailures = new Counter('auth_failures');

Checks vs thresholds

Checks are assertions that record a pass rate but never fail the test. Thresholds are the pass/fail criteria — they set the exit code, which is what CI cares about.

thresholds: {
  'http_req_failed': ['rate<0.01'],
  'http_req_duration{endpoint:submit}': ['p(95)<500', 'p(99)<1200'],
  'checks{type:critical}': ['rate>0.99'],
  'http_req_duration': [{ threshold: 'p(99)<2000', abortOnFail: true, delayAbortEval: '1m' }],
}

abortOnFail stops the run early once the system is clearly broken — saves you 20 minutes of hammering a dead service.

Tagging — and the URL cardinality trap

Dynamic URLs create one metric series per URL and blow up your output. Always name them:

http.get(`${BASE}/api/patients/${id}/`, {
  tags: { name: '/api/patients/:id', endpoint: 'patient_detail' },
});

group() adds a group tag and nests results. Note that group does not create a transaction boundary — it’s labelling, not timing scope, unless you use the group duration metric.

HTTP specifics

// Parallel requests within one iteration
const responses = http.batch([
  ['GET', `${BASE}/api/patients/`, null, params],
  ['GET', `${BASE}/api/questionnaires/`, null, params],
]);

// Async, when you need Promise semantics
const res = await http.asyncRequest('GET', url, null, params);

Connection reuse, cookie jars, and redirect following are on by default. http.get(url, { responseType: 'none' }) or the global discardResponseBodies: true cuts memory dramatically when you don’t inspect bodies.

Beyond HTTP

  • k6/browser — Playwright-like async API driving real Chromium. Use it sparingly: browser VUs cost ~100× a protocol VU. The correct pattern is a mostly-protocol test with one small browser scenario to capture Core Web Vitals. Recent versions can also attach to an existing Chromium over CDP, mirroring Playwright’s browserType.connect.
  • k6/net/grpc — unary and streaming, with .load() for protos.
  • k6/experimental/websockets — the standard-ish WebSocket API.
  • xk6 extensions — SQL, Kafka, MQTT, Redis, AMQP, and xk6-disruptor for fault injection. Built by compiling a custom binary: xk6 build --with github.com/grafana/xk6-sql.

Outputs and distributed runs

k6 run script.js --out experimental-prometheus-rw   # → Prometheus/Grafana
k6 run script.js --out json=results.json
k6 run script.js --out csv=results.csv

Set K6_PROMETHEUS_RW_TREND_STATS=p(95),p(99),max or you get useless trend aggregation.

For load beyond one machine: k6-operator on Kubernetes (a TestRun CRD that shards a script across N pods), or Grafana Cloud k6. Note that percentiles do not aggregate correctly across shards unless the backend supports it — sum counters, don’t average p95s.

TypeScript and modules

Modern k6 runs ES2015+ natively via Sobek and strips TypeScript types directly — no Babel, no build step for plain TS. You only need a bundler (esbuild/webpack) if you import npm packages, since there’s no Node API surface: no fs, no crypto module, no Buffer, no process. Use k6/crypto, k6/encoding, open(), and __ENV.

import { Faker } from 'k6/x/faker' style imports only work with a custom binary.

CLI worth knowing

k6 new                        # scaffold a script
k6 run --vus 10 --duration 30s script.js
k6 run --http-debug=full      # dump requests/responses
k6 inspect script.js          # resolved options, no execution
k6 archive script.js          # bundle script + data into .tar for CI/cloud
k6 cloud run --local-execution # run locally, stream results to Grafana Cloud

Also k6 Studio — a desktop app that records a browser session and generates a script. Useful as a starting skeleton, not as a finished test.

Pitfalls

  • Your generator is the bottleneck. Raise ulimit -n, widen the ephemeral port range, and watch k6’s own CPU. Above ~70% CPU your latency numbers include queueing in k6 itself.
  • sleep() inside an arrival-rate executor still consumes a VU for its duration. That’s what preAllocatedVUs/maxVUs are sized for, and why dropped_iterations climbs if you under-allocate.
  • DNS is cached per VU by default. If you’re testing behind a load balancer with rotating IPs, you may be hitting one backend the whole run.
  • Thresholds on http_req_duration without tags average across all endpoints and tell you nothing.
  • setup() timeouts — default is 60s. Seeding a large fixture in setup() will silently kill the run.
  • k6 doesn’t do think-time modelling for you. No pacing means an unrealistic burst pattern.

For your Django stack specifically

Bake the auth dance into setup() once and pass the token down — re-authing per iteration load-tests your JWT signing, not your app. Tag every request by view name so http_req_duration{endpoint:...} lines up with what you see in pg_stat_statements. And run the harness from a different Proxmox VM than the app; loopback has no TLS handshake, no MTU, and no realistic connection cost.

Back to top