TL;DR:

  • k6 tests are JavaScript files — write them in your editor, run them from your terminal, commit them to your repo alongside your application code
  • The three metrics that matter first: p95 response time, error rate, and requests per second — ignore the histogram until you’ve understood these
  • Grafana Cloud k6 (formerly k6 Cloud) runs distributed load from multiple regions without managing infrastructure; the free tier covers most developer needs

Most teams treat load testing as something that happens before a major launch — a one-time exercise, often outsourced, with results that tell you the system survives a test that looked nothing like real traffic. k6 changes this because it fits into the same workflow as unit testing: you write scripts in code, run them locally or in CI, and get results in your terminal.

Why k6 Specifically

The load testing tool landscape has old options (JMeter — XML configuration, Java, heavy) and newer ones (Locust — Python, pleasant but limited). k6 sits in a different position:

  • Scripts are plain JavaScript (not TypeScript natively, but you can compile TS) — readable, version-controllable, writable by any backend developer without learning a new domain language
  • Runs as a static binary — no JVM, no runtime dependencies, single executable that works on Linux, Mac, and Windows
  • Built-in metrics — response time percentiles, error rates, throughput, connection times all tracked automatically
  • Thresholds — you define pass/fail criteria in the script; the CLI exits non-zero if thresholds aren’t met, making CI integration natural
  • Extensions — browser testing (k6 browser), Kafka, Redis, gRPC, and more through the extension system

The Grafana acquisition in 2021 has meant good native integration with Grafana dashboards for real-time visualisation during test runs.

A Basic Test

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

export const options = {
  vus: 10,        // 10 concurrent virtual users
  duration: '30s', // run for 30 seconds
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
    http_req_failed: ['rate<0.01'],   // less than 1% errors
  },
};

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

Run it: k6 run script.js

The output gives you a live summary during the run and a final summary with all metrics. If your thresholds fail, the exit code is non-zero — straightforward to integrate with any CI system.

Writing Realistic Scenarios

The biggest mistake in load testing is sending uniform traffic with no think time, no session state, and no variation in endpoints. Real users don’t do this. Your test should model real usage patterns:

Ramping load — most production traffic doesn’t start at peak immediately. Use stages to model ramp-up:

export const options = {
  stages: [
    { duration: '2m', target: 50 },   // ramp up to 50 VUs over 2 minutes
    { duration: '5m', target: 50 },   // hold at 50 VUs for 5 minutes
    { duration: '2m', target: 100 },  // ramp up to 100
    { duration: '5m', target: 100 },  // hold at 100
    { duration: '2m', target: 0 },    // ramp down
  ],
};

User journeys — if your API has a browse-search-checkout flow, write the test as that flow, not three independent endpoint tests:

export default function () {
  // Browse
  const browse = http.get(`${BASE_URL}/products?category=electronics`);
  check(browse, { 'browse 200': (r) => r.status === 200 });
  sleep(randomBetween(1, 3));

  // Search
  const search = http.get(`${BASE_URL}/products?q=laptop`);
  check(search, { 'search 200': (r) => r.status === 200 });
  sleep(randomBetween(2, 5));

  // Add to cart
  const addToCart = http.post(`${BASE_URL}/cart`, JSON.stringify({
    product_id: 'prod_123',
    quantity: 1,
  }), { headers: { 'Content-Type': 'application/json' } });
  check(addToCart, { 'cart 200': (r) => r.status === 200 });
}

Parameterised data — don’t test the same user ID or product ID on every request. Use a CSV or in-memory array to vary inputs:

import { SharedArray } from 'k6/data';

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)];
  // use user.id, user.token, etc.
}

Reading the Results

The k6 output has a lot of metrics. The ones to focus on first:

MetricWhat it meansTypical threshold
http_req_durationTotal request time including DNS, TLS, transferp(95) < your SLA
http_req_failedRate of failed requests (non-2xx or connection errors)rate < 0.01 (1%)
http_reqsTotal requests per second (throughput)Depends on expected load
http_req_waitingTime waiting for first byte (TTFB)Reveals server-side processing time
vus_maxPeak concurrent users reachedConfirms your test ran as configured

The p95 value for http_req_duration is your most actionable number: it tells you what 95% of users experienced, filtering out occasional outliers. If your p95 is 450ms and your SLA is 500ms, you’re close to the edge and need to understand what’s causing the slowest 5%.

Integrating with CI

k6’s non-zero exit on threshold failure makes CI integration natural. A GitHub Actions step:

- name: Run load test
  run: |
    k6 run --out json=results.json tests/load/smoke.js
  env:
    K6_API_TARGET: ${{ secrets.STAGING_API_URL }}

Run a smoke test (2-5 VUs, 1 minute) on every pull request — fast enough not to block PRs, catches obvious regressions. Run a load test (realistic peak VUs, 10+ minutes) on merges to main or on a nightly schedule.

k6 Browser for Frontend Performance

The k6 browser module adds Chromium-based browser testing alongside your HTTP tests. This is useful for measuring real user metrics (Core Web Vitals) rather than just API response times:

import { browser } from 'k6/browser';

export const options = {
  scenarios: {
    browser: {
      executor: 'constant-vus',
      options: { browser: { type: 'chromium' } },
    },
  },
};

export default async function () {
  const page = await browser.newPage();
  await page.goto('https://example.com/products');
  const lcp = await page.evaluate(() =>
    new Promise(resolve => {
      new PerformanceObserver((list) => {
        const entries = list.getEntries();
        resolve(entries[entries.length - 1].startTime);
      }).observe({ type: 'largest-contentful-paint', buffered: true });
    })
  );
  console.log(`LCP: ${lcp}ms`);
  await page.close();
}

Browser tests are significantly heavier per VU than HTTP tests — plan for 5-10 browser VUs where you’d use 100+ HTTP VUs.

When Not to Use k6

k6 is the right tool when you’re testing APIs, services, or user flows at the network level. It’s not the right tool for:

  • Database query performance — use EXPLAIN ANALYZE and query-level profiling tools
  • Individual function benchmarks — use language-native benchmarking (Go’s testing.B, Python timeit, Criterion for Rust)
  • Stress testing at extreme scale (millions of requests per second) — distributed k6 with Grafana Cloud or self-hosted k6 operator works, but the economics and infrastructure complexity change

The practical starting point for any team: write a smoke test for your main API endpoint, get it running in CI, and observe how response times change across deployments. That alone will catch more performance regressions than most teams currently catch, with about an hour of setup.