TL;DR:

  • k6 is a load testing tool where tests are JavaScript scripts — it uses Go under the hood for performance but your test logic is plain JS, so there’s no XML config or proprietary DSL to learn
  • A basic k6 test is 5–10 lines; a realistic production API test is 50–100 lines covering authentication, scenario weights, and SLA thresholds
  • k6 integrates directly with Grafana for real-time dashboard output, and the Grafana k6 Cloud offering (or self-hosted Grafana setup) lets you store and compare results across test runs

Why Load Test at All

Most teams discover performance problems in production, where discovery is expensive. Load testing shifts that discovery left — you find out your API can’t handle 500 concurrent users during a scheduled test rather than during a product launch.

k6 hits a useful design point: it’s more capable than most “just ping the URL” tools but less ceremonial than enterprise testing platforms. Tests are code, so they live in version control alongside your application. The output is structured and scriptable, so CI pipelines can fail on performance regressions just as they fail on broken unit tests.

Installation

# macOS
brew install k6

# Windows
choco install k6

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

# Docker
docker run --rm -i grafana/k6 run - <script.js

Your First Test

// simple-test.js
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  vus: 10,          // 10 virtual users
  duration: "30s",  // run for 30 seconds
};

export default function () {
  const response = http.get("https://api.example.com/products");

  check(response, {
    "status is 200": (r) => r.status === 200,
    "response time < 500ms": (r) => r.timings.duration < 500,
  });

  sleep(1); // wait 1 second between iterations (simulates think time)
}
k6 run simple-test.js

The output shows requests per second, median response time, 95th and 99th percentile latencies, error rates, and check pass rates. 10 VUs with a 1-second sleep means roughly 10 requests per second across the 30-second run — 300 total requests.

Realistic API Tests with Authentication

Most APIs require authentication. k6 handles this in the setup function, which runs once before the main test and can return data to the test function:

// api-test.js
import http from "k6/http";
import { check, sleep } from "k6";
import { SharedArray } from "k6/data";

const BASE_URL = __ENV.BASE_URL || "https://api.example.com";

// SharedArray: loaded once, shared across all VUs (memory-efficient)
const users = new SharedArray("users", function () {
  return JSON.parse(open("./test-users.json"));
});

export const options = {
  thresholds: {
    http_req_duration: ["p(95)<500"],  // 95th percentile under 500ms
    http_req_failed: ["rate<0.01"],     // error rate under 1%
    checks: ["rate>0.99"],              // >99% of checks pass
  },
  scenarios: {
    normal_load: {
      executor: "ramping-vus",
      startVUs: 0,
      stages: [
        { duration: "2m", target: 50 },   // ramp up to 50 VUs
        { duration: "5m", target: 50 },   // hold at 50 VUs
        { duration: "2m", target: 0 },    // ramp down
      ],
    },
  },
};

export function setup() {
  // Authenticate and return token for use in tests
  const user = users[0];
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: user.email,
    password: user.password,
  }), { headers: { "Content-Type": "application/json" } });

  check(loginRes, { "login successful": (r) => r.status === 200 });
  return { token: loginRes.json("access_token") };
}

export default function (data) {
  const params = {
    headers: {
      "Authorization": `Bearer ${data.token}`,
      "Content-Type": "application/json",
    },
  };

  // Browse products
  const productsRes = http.get(`${BASE_URL}/products?page=1&limit=20`, params);
  check(productsRes, {
    "products status 200": (r) => r.status === 200,
    "products returns items": (r) => r.json("items").length > 0,
  });

  sleep(Math.random() * 2 + 0.5); // random think time 0.5–2.5 seconds

  // Add to cart
  const cartRes = http.post(`${BASE_URL}/cart`, JSON.stringify({
    product_id: "prod_123",
    quantity: 1,
  }), params);
  check(cartRes, { "add to cart success": (r) => r.status === 201 });

  sleep(1);
}

Thresholds: Making Tests Pass or Fail

Without thresholds, k6 runs and reports — but CI doesn’t know if the performance was acceptable. Thresholds turn k6 into a quality gate:

export const options = {
  thresholds: {
    // All HTTP requests complete in under 500ms (95th percentile)
    http_req_duration: ["p(95)<500"],
    
    // Under 1% error rate
    http_req_failed: ["rate<0.01"],
    
    // Specific endpoint: checkout must be under 1 second
    "http_req_duration{endpoint:checkout}": ["p(99)<1000"],
    
    // Custom metric you define and track
    order_creation_duration: ["avg<300", "p(95)<600"],
  },
};

When any threshold is breached, k6 exits with a non-zero status code, which fails the CI step.

Custom Metrics

Track business-level metrics beyond the built-in HTTP stats:

import { Trend, Rate } from "k6/metrics";

const orderDuration = new Trend("order_creation_duration", true); // in milliseconds
const orderFailRate = new Rate("order_fail_rate");

export default function (data) {
  const start = Date.now();
  
  const res = http.post(`${BASE_URL}/orders`, orderPayload, params);
  
  orderDuration.add(Date.now() - start);
  orderFailRate.add(res.status !== 201);
  
  check(res, { "order created": (r) => r.status === 201 });
}

Scenarios for Realistic Traffic Patterns

Real traffic isn’t 50 VUs all doing the same thing. Scenarios let you model mixed workloads:

export const options = {
  scenarios: {
    // Browsing users: many, low activity
    browsers: {
      executor: "constant-vus",
      vus: 100,
      duration: "10m",
      exec: "browseProducts",
    },
    // Purchasing users: fewer, higher activity
    purchasers: {
      executor: "constant-arrival-rate",
      rate: 5,          // 5 purchases per second
      timeUnit: "1s",
      duration: "10m",
      preAllocatedVUs: 20,
      maxVUs: 50,
      exec: "purchase",
    },
  },
};

export function browseProducts() { /* ... */ }
export function purchase() { /* ... */ }

CI/CD Integration

# GitHub Actions: run k6 test on every PR
name: Load Test

on:
  pull_request:
    paths:
      - "api/**"
      - "k6/**"

jobs:
  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: k6/api-test.js
        env:
          BASE_URL: ${{ secrets.STAGING_API_URL }}
          K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}  # optional: send results to Grafana Cloud

For local development, run against a local server:

# Run against local dev server with environment variable
BASE_URL=http://localhost:3000 k6 run api-test.js

# Output JSON results for post-processing
k6 run --out json=results.json api-test.js

# Stream to a local InfluxDB instance for Grafana dashboard
k6 run --out influxdb=http://localhost:8086/k6 api-test.js

Grafana Output for Real-Time Dashboards

k6’s --out influxdb flag streams metrics in real time to an InfluxDB instance, which Grafana can display as a live dashboard during the test run. Grafana Labs provides an official k6 dashboard template (Dashboard ID: 2587) that shows VUs, RPS, response times, and error rates in real time.

For teams already running Grafana + InfluxDB for application monitoring, this lets you overlay load test metrics with application-level metrics (database query times, cache hit rates, queue depth) during the same time window — the most useful view for diagnosing what breaks under load.

When to Use k6 vs Other Tools

  • k6 vs Artillery: Both are JavaScript-based load testing tools. k6 generally has better performance at high VU counts and tighter Grafana integration. Artillery has simpler YAML-based test definitions for straightforward cases.
  • k6 vs Locust: Locust uses Python (better if your team knows Python better than JS), has a web UI for interactive test control, but runs slower per VU than k6 for the same server resources.
  • k6 vs JMeter: JMeter is more feature-complete for complex protocol testing (JDBC, JMS, etc.) but has a steep XML-based configuration learning curve. For REST API and HTTP load testing, k6 is faster to set up and maintain.

References