TL;DR — Quick Summary

Master Grafana k6 for load and performance testing. Virtual users, thresholds, scenarios, CI/CD integration, browser testing, and Grafana dashboards.

Grafana k6 is a developer-first load testing tool that runs JavaScript test scripts on a high-performance Go runtime, giving you code-as-test reproducibility, version control, and seamless CI/CD integration. This guide covers everything from your first smoke test to production-grade scenarios with custom metrics, browser testing, and Grafana dashboards.

Prerequisites

  • Node.js not required — k6 has its own embedded JavaScript runtime (Goja, a Go-based ES6+ engine).
  • k6 installed — Homebrew, apt, Docker, or binary release.
  • Basic JavaScript knowledge — functions, objects, loops.
  • A target service — any HTTP API, WebSocket server, or gRPC endpoint.

k6 Architecture

k6 runs JavaScript test scripts inside a Go runtime. Each virtual user (VU) is a lightweight goroutine executing your default function in a loop. Key concepts:

  • VUs — Concurrent virtual users. Each runs your script independently.
  • Iterations — One execution of the default function by one VU.
  • Checks — Assertions (like status === 200). Failing checks do NOT stop the test.
  • Thresholds — Pass/fail criteria (like p(95) < 250). Breaching one exits k6 with code 99.
  • Metrics — Built-in (http_req_duration, http_reqs, vus) and custom (Trend, Counter, Gauge, Rate).

Built-in protocols:

ProtocolModuleNotes
HTTP/1.1 & HTTP/2k6/httpAutomatic HTTP/2 negotiation
WebSocketk6/experimental/websocketsFull WS frame support
gRPCk6/net/grpcUnary and streaming
Redisk6/experimental/redisVia xk6-redis extension
Browserk6/browserReal Chromium via Playwright

Installation

# macOS (Homebrew)
brew install k6

# Ubuntu / Debian
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

# Docker
docker pull grafana/k6

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

xk6 extensions let you build a custom k6 binary with extra protocols:

go install go.k6.io/xk6/cmd/xk6@latest
xk6 build --with github.com/grafana/xk6-redis

Writing Tests in JavaScript

Basic HTTP test

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';

// Custom metrics
const apiLatency = new Trend('api_latency', true); // true = display as ms
const apiErrors  = new Rate('api_errors');
const apiCalls   = new Counter('api_calls');

export const options = {
  stages: [
    { duration: '30s', target: 20 },  // ramp up
    { duration: '1m',  target: 20 },  // plateau
    { duration: '10s', target: 0  },  // ramp down
  ],
  thresholds: {
    http_req_duration:     ['p(95)<250'],   // 95th percentile < 250ms
    http_req_failed:       ['rate<0.01'],   // error rate < 1%
    api_latency:           ['p(99)<500'],   // custom metric threshold
  },
};

export default function () {
  group('User API', () => {
    // GET request
    const listRes = http.get(`${__ENV.BASE_URL}/api/users`, {
      headers: { 'Authorization': `Bearer ${__ENV.API_TOKEN}` },
    });
    check(listRes, {
      'list status 200':      (r) => r.status === 200,
      'list returns array':   (r) => Array.isArray(r.json()),
    });
    apiLatency.add(listRes.timings.duration);
    apiErrors.add(listRes.status !== 200);
    apiCalls.add(1);

    // POST request
    const createRes = http.post(
      `${__ENV.BASE_URL}/api/users`,
      JSON.stringify({ name: 'k6 user', email: `k6-${Date.now()}@test.com` }),
      { headers: { 'Content-Type': 'application/json' } }
    );
    check(createRes, { 'create status 201': (r) => r.status === 201 });

    // Extract and reuse value (correlation)
    const userId = createRes.json('id');

    // DELETE
    const delRes = http.del(`${__ENV.BASE_URL}/api/users/${userId}`);
    check(delRes, { 'delete status 204': (r) => r.status === 204 });
  });

  sleep(1); // pacing — think time between iterations
}

Run with environment variables:

k6 run -e BASE_URL=https://api.example.com -e API_TOKEN=secret script.js

Load Test Types

Smoke test — verify script works

export const options = { vus: 1, duration: '30s' };

Load test — normal expected traffic

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

Stress test — beyond normal capacity

export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 200 },
    { duration: '2m', target: 300 }, // push beyond normal
    { duration: '5m', target: 300 },
    { duration: '2m', target: 0   },
  ],
};

Spike test — sudden surge

export const options = {
  stages: [
    { duration: '10s', target: 1000 }, // instant spike
    { duration: '1m',  target: 1000 },
    { duration: '10s', target: 0    }, // instant drop
  ],
};

Soak test — extended duration (find memory leaks)

export const options = {
  stages: [
    { duration: '5m',  target: 100 },
    { duration: '8h',  target: 100 }, // 8-hour soak
    { duration: '5m',  target: 0   },
  ],
};

Breakpoint test — find the limit

export const options = {
  stages: [
    { duration: '2h', target: 20000 }, // slowly ramp to extreme load
  ],
  thresholds: {
    http_req_failed: [{ threshold: 'rate<0.01', abortOnFail: true }],
  },
};

The abortOnFail: true flag stops the test the moment the error rate breaches 1%, recording the VU count at the breaking point.


Scenarios and Executors

Scenarios give fine-grained control over how VUs are scheduled:

export const options = {
  scenarios: {
    // Fixed arrival rate — 100 requests per second regardless of VU count
    constant_load: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
    // Separate scenario for a different endpoint
    browse_api: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 50 },
        { duration: '3m', target: 50 },
        { duration: '1m', target: 0  },
      ],
      exec: 'browseScenario', // different function
    },
  },
};

export function browseScenario() {
  http.get(`${__ENV.BASE_URL}/browse`);
  sleep(2);
}

Executor reference:

ExecutorUse case
shared-iterationsFixed total iterations shared across VUs
per-vu-iterationsEach VU completes N iterations
constant-vusFixed VU count for a duration
ramping-vusStages-based VU ramp
constant-arrival-rateFixed RPS / TPS regardless of VU count
ramping-arrival-rateVariable RPS over stages
externally-controlledREST API controls VU count at runtime

Data Parameterization

import { SharedArray } from 'k6/data';
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';

// SharedArray loads data once, shares across all VUs (memory efficient)
const users = new SharedArray('users', function () {
  return papaparse.parse(open('./test-users.csv'), { header: true }).data;
});

export default function () {
  const user = users[__VU % users.length]; // round-robin per VU
  http.post('/login', JSON.stringify({ email: user.email, password: user.password }));
  sleep(1);
}

Setup and Teardown

export function setup() {
  // Runs once before all VUs start — create test data, get auth token
  const res = http.post('/auth/token', JSON.stringify({ client: 'k6' }));
  return { token: res.json('access_token') }; // returned value passed to default()
}

export default function (data) {
  http.get('/api/protected', { headers: { Authorization: `Bearer ${data.token}` } });
  sleep(1);
}

export function teardown(data) {
  // Runs once after all VUs finish — clean up test data
  http.del(`/auth/token/${data.token}`);
}

Output and Visualization

# JSON output
k6 run --out json=results.json script.js

# CSV output
k6 run --out csv=results.csv script.js

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

# Prometheus remote write
k6 run --out experimental-prometheus-rw script.js

InfluxDB + Grafana setup:

docker network create k6

docker run -d --name influxdb --network k6 \
  -p 8086:8086 \
  -e INFLUXDB_DB=k6 \
  influxdb:1.8

docker run -d --name grafana --network k6 \
  -p 3000:3000 \
  grafana/grafana

# Import Grafana dashboard ID 2587 (official k6 dashboard)
# Add InfluxDB data source: http://influxdb:8086, database: k6

Run the test against the containerized stack:

docker run --rm -i --network k6 \
  -e BASE_URL=http://my-app:8080 \
  grafana/k6 run --out influxdb=http://influxdb:8086/k6 - < script.js

CI/CD Integration

GitHub Actions

name: Load Test
on:
  push:
    branches: [main]

jobs:
  k6-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: tests/load/api.js
          flags: --vus 50 --duration 2m
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}
          API_TOKEN: ${{ secrets.API_TOKEN }}

      - name: Upload results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: k6-results
          path: results.json

k6 exits with code 99 on threshold breach, causing the Actions step to fail automatically — no extra logic needed.

GitLab CI

load-test:
  image: grafana/k6
  stage: test
  script:
    - k6 run --vus 20 --duration 1m tests/load/api.js
  variables:
    BASE_URL: $STAGING_URL
  artifacts:
    when: always
    paths: [results.json]

Browser Testing with k6

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

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

export default async function () {
  const page = await browser.newPage();

  try {
    await page.goto('https://example.com');

    // Interact with the page
    await page.locator('input[name="email"]').fill('user@test.com');
    await page.locator('button[type="submit"]').click();
    await page.waitForNavigation();

    check(page, {
      'dashboard visible': (p) => p.locator('h1').textContent() === 'Dashboard',
    });

    // Collect Web Vitals (LCP, FID, CLS)
    const metrics = await page.evaluate(() => ({
      lcp: window.__webVitals?.lcp,
      cls: window.__webVitals?.cls,
    }));
    console.log('Web Vitals:', JSON.stringify(metrics));
  } finally {
    await page.close();
  }
}

k6 vs Other Load Testing Tools

ToolLanguageProtocol supportVU modelCI/CDLearning curve
k6JavaScriptHTTP, WS, gRPC, BrowserGoroutineNativeLow
JMeterXML/GUIHTTP, JMS, FTP, JDBCThreadPluginHigh
GatlingScala/DSLHTTP, WSAsync actorMaven/GradleMedium
LocustPythonHTTP, customAsync coroutineCLILow
ArtilleryYAML/JSHTTP, WS, Socket.ioNode.jsNativeLow
wrkLuaHTTP onlyEpollManualVery low
heyNoneHTTP onlyGoroutineManualVery low

When to choose k6: developer teams who want code-as-test, CI/CD-native thresholds, protocol diversity, and direct Grafana visualization without managing a separate test cluster.


Gotchas and Edge Cases

  • k6 is NOT Node.js. You cannot use require(), fs, or most npm packages. Use the built-in open() for local files and jslib.k6.io for shared utilities.
  • Checks are not assertions. A failed check does not stop the test or fail the threshold by itself — only threshold breaches do. Wire check results to a Rate metric if you want threshold-enforced check pass rates.
  • sleep() is mandatory for realistic load. Without sleep(), VUs hammer the target as fast as possible, simulating a denial-of-service, not real user traffic.
  • SharedArray vs open(). For large CSV/JSON datasets, use SharedArray — it parses once and shares memory across all VUs. Using open() in default() parses the file for every single iteration.
  • HTTP/2 push. k6 receives server-pushed resources but does not process them — they are counted in the connection but not as separate requests.
  • TLS certificate errors. Use k6 run --insecure-skip-tls-verify for self-signed certs in staging. Never use this flag in production testing.

Troubleshooting

ProblemSolution
ERRO[...] GoError: dial tcp: connection refusedTarget service is not running or wrong BASE_URL
Thresholds pass locally but fail in CICI runner CPU limits affect response times; use --vus appropriate to CI resources
WARN too many open filesRaise ulimit: ulimit -n 65535 before running k6
InfluxDB dashboard shows no dataVerify InfluxDB is reachable; check --out influxdb= URL matches container name
Browser tests fail with context canceledAdd await page.waitForNavigation() after click; browser tests need async/await
k6 exits 0 despite errorsChecks failing does NOT cause non-zero exit — add a Rate threshold on error rate

Summary

  • k6 runs JavaScript on a Go runtime — lightweight VUs, code-as-test, Git-friendly.
  • Test types — smoke, load, stress, spike, soak, breakpoint — each targeting a different risk.
  • Thresholds enforce pass/fail in CI; abortOnFail stops runaway breakpoint tests.
  • Scenarios with executors separate arrival-rate control from VU count management.
  • SharedArray parameterizes large datasets without per-VU memory overhead.
  • Grafana dashboard 2587 visualizes real-time results from InfluxDB output.
  • k6 browser module measures Web Vitals alongside API performance in the same test run.