Install

# 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 update && sudo apt install k6

# macOS
brew install k6

# Or via mise / docker
mise use -g k6@latest
docker run --rm -i grafana/k6 run - < test.js

k6 version

The simplest test

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 10,            // virtual users
  duration: '30s',
};

export default function () {
  const res = http.get('https://api.example.com/users');
  check(res, {
    'is 200': (r) => r.status === 200,
    'fast enough': (r) => r.timings.duration < 200,
  });
  sleep(1);
}
k6 run load-test.js

10 virtual users, each hitting the URL once per second for 30 seconds. After completion, k6 prints a summary:

     ✓ is 200
     ✓ fast enough

     checks.........................: 100.00% ✓ 300        ✗ 0
     http_req_duration..............: avg=87ms     min=42ms    med=85ms    max=298ms
       { expected_response:true }...: avg=87ms     min=42ms    med=85ms    max=298ms
     http_req_failed................: 0.00%   ✓ 0          ✗ 300
     http_reqs......................: 300     9.9/s
     iteration_duration.............: avg=1.08s    min=1.04s   med=1.08s   max=1.30s
     iterations.....................: 300     9.9/s
     vus............................: 10
     vus_max........................: 10

Thresholds: turn a test into a pass/fail

export const options = {
  vus: 50,
  duration: '5m',
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'],   // 95th < 500ms, 99th < 1s
    http_req_failed: ['rate<0.01'],                    // <1% failures
    checks: ['rate>0.99'],                             // >99% checks pass
  },
};

If any threshold is breached, k6 exits with non-zero status. Wire into CI:

# GitHub Actions
- run: k6 run load-test.js

Pull request fails if the load test regresses. Performance gate without standing up a separate tool.

Scenarios: ramp + steady + spike

export const options = {
  scenarios: {
    ramp_up: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 100 },     // ramp to 100 VUs
        { duration: '5m', target: 100 },     // hold
        { duration: '2m', target: 0 },       // ramp down
      ],
      gracefulRampDown: '30s',
    },
    spike: {
      executor: 'ramping-arrival-rate',
      startRate: 0,
      timeUnit: '1s',
      preAllocatedVUs: 200,
      stages: [
        { duration: '1m', target: 500 },     // 500 req/s
        { duration: '2m', target: 500 },     // hold
      ],
      startTime: '11m',                       // run after ramp_up finishes
    },
  },
};

Mix scenarios in a single test: warm-up users, then a sudden spike, then a long hold. Realistic compound load patterns.

Executors

  • constant-vus — fixed VU count for a duration (default)
  • ramping-vus — VU count changes over time
  • shared-iterations / per-vu-iterations — total iteration counts
  • constant-arrival-rate — X requests/sec regardless of duration of each (use this for closed-loop limit testing)
  • ramping-arrival-rate — same, but with stages
  • externally-controlled — change VUs at runtime via API

For "what's the steady-state throughput at fixed RPS?" use constant-arrival-rate. For "how does latency degrade as we add users?" use ramping-vus.

Realistic test patterns

import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';

// Load test data once, share across VUs (memory-efficient)
const users = new SharedArray('users', function () {
  return JSON.parse(open('./test-users.json'));
});

export default function () {
  const user = users[Math.floor(Math.random() * users.length)];

  group('Auth flow', () => {
    const login = http.post('https://api.example.com/login', {
      email: user.email,
      password: user.password,
    });
    check(login, { 'login ok': (r) => r.status === 200 });

    const token = login.json('token');
    const params = { headers: { 'Authorization': `Bearer ${token}` } };

    group('Browse', () => {
      http.get('https://api.example.com/products', params);
      sleep(2);
      http.get('https://api.example.com/orders', params);
    });

    group('Purchase', () => {
      http.post('https://api.example.com/cart', { product_id: 42 }, params);
      sleep(1);
      http.post('https://api.example.com/checkout', null, params);
    });
  });

  sleep(Math.random() * 3 + 1);   // 1-4 second think time
}

Output to Prometheus / InfluxDB

# Stream metrics in real time to Prometheus remote-write
k6 run -o experimental-prometheus-rw \
    -e K6_PROMETHEUS_RW_SERVER_URL=https://prometheus.example.com/api/v1/write \
    load-test.js

# Or InfluxDB
k6 run -o influxdb=http://influx.lab:8086/k6 load-test.js

# Or just CSV
k6 run -o csv=results.csv load-test.js

Pair with Grafana dashboards (the k6 community has pre-built ones at grafana.com/dashboards) for live load-test observability.

Browser testing (k6 browser)

For end-to-end with actual browser sessions (Playwright-style), k6 has a browser module that runs Chromium:

import { browser } from 'k6/browser';

export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      options: { browser: { type: 'chromium' } },
    },
  },
};

export default async function () {
  const page = await browser.newPage();
  await page.goto('https://app.example.com');
  await page.locator('#email').type('amir@example.com');
  await page.locator('button[type=submit]').click();
  await page.waitForSelector('h1');
  // check, screenshot, assert UI
}

Heavy compared to HTTP-only tests; reserve for "end-to-end critical paths under load."

Distributed runs

For load that exceeds one machine's capacity, k6's k6 cloud (paid SaaS) handles distribution. For self-hosted, run k6 in Kubernetes with the k6-operator: define TestRun CRDs, the operator spawns N pods that run the test in parallel.

What k6 isn't

  • Not a synthetic monitoring tool — for "ping our prod endpoints every minute," use uptime-kuma (see that tutorial) or a dedicated synthetic-monitoring service.
  • Not a chaos-engineering tool — for fault injection, use Chaos Mesh or Litmus on top of Kubernetes.
  • Not Selenium — k6 browser is lighter; for full QA test suites against UI, Playwright / Cypress fit better.

For "how does this API hold up at 1000 RPS?" k6 is the right tool.