← All articles
TESTING k6: Modern Load Testing for APIs and Web Services 2026-03-04 · 5 min read · k6 · load-testing · performance

k6: Modern Load Testing for APIs and Web Services

Testing 2026-03-04 · 5 min read k6 load-testing performance api-testing javascript ci-cd grafana

k6: Modern Load Testing for APIs and Web Services

Most load testing tools feel like they were designed for QA teams, not developers. k6 is different: tests are JavaScript files, results pipe cleanly into Grafana, and the CLI integrates naturally into CI pipelines.

k6 runs test scenarios that send real HTTP requests, measures response times and error rates, and reports whether your service met its performance targets. It's designed for testing at realistic scale — from a few dozen concurrent users to millions of requests per second (using k6 Cloud).

Installation

# macOS
brew install k6

# Linux (Debian/Ubuntu)
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb 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

Your First Test

Create a file test.js:

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

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

export default function () {
  const res = http.get('https://httpbin.org/get');
  
  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });
  
  sleep(1); // think time between requests
}

Run it:

k6 run test.js

k6 outputs a summary with request counts, response times (median, p95, p99), failure rates, and whether checks passed.

Test Structure

Every k6 test has the same structure:

// Executed once per test (setup)
export function setup() {
  return { authToken: authenticate() };
}

// Executed by each virtual user in a loop
export default function (data) {
  // data comes from setup()
  makeAuthenticatedRequest(data.authToken);
}

// Executed once when all VUs finish (teardown)
export function teardown(data) {
  // cleanup
}

The default function is the hot path — keep it focused on the requests you're testing.

Load Patterns with Scenarios

Real traffic isn't constant. k6 scenarios model traffic patterns:

export const options = {
  scenarios: {
    // Ramp up, hold, ramp down
    ramp_up: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 50 },  // ramp to 50 users
        { duration: '3m', target: 50 },  // hold at 50 users
        { duration: '1m', target: 0 },   // ramp down
      ],
    },
    
    // Fixed arrival rate (RPS)
    steady_rps: {
      executor: 'constant-arrival-rate',
      rate: 100,            // 100 requests per second
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 50,  // pre-allocate VUs to handle load
    },
  },
};

Ramping VUs: Good for simulating user growth and finding breaking points. Constant arrival rate: Good for testing at a specific RPS target. k6 adjusts VU count to maintain the rate.

Realistic Test Scenarios

Authenticated API Calls

import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = 'https://api.example.com';

export function setup() {
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: '[email protected]',
    password: 'password',
  }), { headers: { 'Content-Type': 'application/json' } });
  
  return { token: loginRes.json('access_token') };
}

export default function (data) {
  const headers = {
    'Authorization': `Bearer ${data.token}`,
    'Content-Type': 'application/json',
  };
  
  // Create a resource
  const createRes = http.post(`${BASE_URL}/items`, JSON.stringify({
    name: 'Test Item',
    value: Math.random(),
  }), { headers });
  
  check(createRes, { 'item created': (r) => r.status === 201 });
  
  // Read it back
  const id = createRes.json('id');
  const getRes = http.get(`${BASE_URL}/items/${id}`, { headers });
  check(getRes, { 'item retrieved': (r) => r.status === 200 });
}

Browser-like Session (Correlation)

When testing flows that depend on dynamic values (CSRF tokens, session IDs), extract them from responses:

import { parseHTML } from 'k6/html';

export default function () {
  // Get login page (to extract CSRF token)
  const loginPage = http.get('https://app.example.com/login');
  const doc = parseHTML(loginPage.body);
  const csrfToken = doc.find('input[name="csrf_token"]').attr('value');
  
  // Submit login with token
  http.post('https://app.example.com/login', {
    username: '[email protected]',
    password: 'password',
    csrf_token: csrfToken,
  });
}

Thresholds: Pass/Fail Criteria

Thresholds make k6 tests deterministic — the test passes or fails based on whether metrics met your targets:

export const options = {
  thresholds: {
    // 95th percentile response time under 500ms
    http_req_duration: ['p(95)<500'],
    
    // 99th percentile under 1 second
    'http_req_duration{name:api_create}': ['p(99)<1000'],
    
    // Error rate below 1%
    http_req_failed: ['rate<0.01'],
    
    // All checks must pass
    checks: ['rate>0.99'],
  },
};

When a threshold fails, k6 exits with code 99, causing CI to fail.

Tag requests with custom names to measure specific operations:

http.get('https://api.example.com/items', { tags: { name: 'api_list' } });
http.post('https://api.example.com/items', body, { tags: { name: 'api_create' } });

Metrics and Output

k6 collects built-in metrics automatically:

Metric Description
http_req_duration Total request time (connect + send + wait + receive)
http_req_waiting Time to first byte (server processing time)
http_req_failed Failed request rate
vus Current virtual users
iterations Total test function executions

Output to different formats:

# JSON output for processing
k6 run --out json=results.json test.js

# InfluxDB for Grafana
k6 run --out influxdb=http://localhost:8086/k6 test.js

# CSV
k6 run --out csv=results.csv test.js

Grafana Dashboard Integration

The Grafana + InfluxDB setup gives you real-time metrics visualization during tests:

  1. Run InfluxDB: docker run -d -p 8086:8086 influxdb:1.8
  2. Import Grafana dashboard #2587 (official k6 dashboard)
  3. Run tests with --out influxdb=http://localhost:8086/k6

You can watch response times, VU count, and RPS in real time as the test runs.

CI Integration

k6 exits with code 0 (pass) or non-zero (fail based on thresholds), making CI integration simple.

GitHub Actions:

name: Load Test
on: [push]

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: grafana/setup-k6-action@v1
      - name: Run load test
        run: k6 run --out json=results.json tests/load.js
      - name: Upload results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: k6-results
          path: results.json

Recommended CI usage: Run load tests against staging, not production. Keep them fast (1-2 minutes max in CI). Reserve longer stress tests for manual runs.

Environment Variables in Tests

import { __ENV } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
const API_KEY = __ENV.API_KEY;

export default function () {
  http.get(`${BASE_URL}/api/items`, {
    headers: { 'X-API-Key': API_KEY },
  });
}

Run with: k6 run -e BASE_URL=https://staging.example.com -e API_KEY=secret test.js

Finding Breaking Points

For stress testing (finding where your service degrades):

export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 100 },
    { duration: '2m', target: 200 },
    { duration: '5m', target: 200 },
    { duration: '2m', target: 300 },
    { duration: '5m', target: 300 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    // These thresholds will likely fail — that's the point
    // Watch where performance degrades in the Grafana dashboard
    http_req_duration: ['p(99)<2000'],
    http_req_failed: ['rate<0.1'],
  },
};

Watch Grafana as VUs increase. The point where p95 response time spikes and error rate increases is your breaking point.

The Developer-Friendly Advantage

k6's design — JavaScript test scripts, CLI-first, CI-native, Grafana integration — makes it the right tool for development teams who want to own performance testing rather than hand it off to a specialist QA team. Tests live in the same repository as the code they test, run in the same CI pipeline, and fail builds when performance regresses.