Skip to content
Codeloom
Testing

Load Testing with k6 for Performance

Learn how to write and run load tests with k6 to find performance bottlenecks before your users do.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • What load testing is and why it matters
  • Writing k6 test scripts in JavaScript
  • Configuring virtual users, stages, and thresholds
  • Interpreting results and finding bottlenecks

Prerequisites

  • Basic JavaScript knowledge
  • A running HTTP API to test against

Why Load Test

Your application works perfectly when one person uses it. But what happens when 500 people hit the same endpoint simultaneously? Load testing answers this question before production traffic does.

Load testing reveals:

  • Throughput limits: how many requests per second your system can handle.
  • Latency degradation: how response times change under load.
  • Resource bottlenecks: database connections, CPU, memory, or network limits.
  • Concurrency bugs: race conditions that only appear under parallel access.

Without load testing, you discover these problems during a product launch, a marketing campaign, or a traffic spike — the worst possible time.

What Is k6

k6 is an open-source load testing tool built by Grafana Labs. You write tests in JavaScript (or TypeScript), and k6 executes them with a high-performance Go runtime. This makes k6 scripts readable and maintainable while still being able to generate serious load from a single machine.

Installation

# macOS
brew 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 C5AD17C747E3415A3642D57D77C6C491D6AC1D68
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 k6 Script

Create a file called load-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 res = http.get("http://localhost:3000/api/users");

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

  sleep(1); // wait 1 second between iterations
}

Run it:

k6 run load-test.js

The default function runs once per iteration per virtual user. With 10 VUs and a 1-second sleep, you get roughly 10 requests per second for 30 seconds.

Understanding the Output

k6 prints a summary after the run:

     checks.........................: 100.00% ✓ 300  ✗ 0
     data_received..................: 1.2 MB  40 kB/s
     data_sent......................: 32 kB   1.1 kB/s
     http_req_duration..............: avg=45ms min=12ms max=230ms p(90)=78ms p(95)=120ms
     http_reqs......................: 300     10/s
     iteration_duration.............: avg=1.05s min=1.01s max=1.23s
     vus............................: 10      min=10 max=10

Key metrics:

  • http_req_duration: response time. The p(95) value (95th percentile) matters most — it shows the worst experience for 95% of requests.
  • http_reqs: total requests and throughput (requests per second).
  • checks: how many of your assertions passed.

Ramping Load with Stages

Real traffic does not start at full speed. Use stages to gradually increase load:

export const options = {
  stages: [
    { duration: "1m", target: 20 },  // ramp up to 20 VUs over 1 minute
    { duration: "3m", target: 20 },  // hold at 20 VUs for 3 minutes
    { duration: "1m", target: 50 },  // ramp up to 50 VUs over 1 minute
    { duration: "3m", target: 50 },  // hold at 50 VUs for 3 minutes
    { duration: "1m", target: 0 },   // ramp down to 0
  ],
};

This pattern is called a stepped load test. It helps you find the exact point where performance degrades.

Thresholds: Pass/Fail Criteria

Thresholds let you define performance requirements that cause k6 to exit with a non-zero code if they are not met:

export const options = {
  vus: 30,
  duration: "2m",
  thresholds: {
    http_req_duration: ["p(95)<300"],     // 95th percentile under 300ms
    http_req_failed: ["rate<0.01"],        // less than 1% errors
    checks: ["rate>0.99"],                // 99% of checks must pass
  },
};

This makes k6 suitable for CI pipelines. If thresholds fail, the pipeline fails.

Testing Multiple Endpoints

Real load tests simulate user journeys across multiple endpoints:

import http from "k6/http";
import { check, group, sleep } from "k6";

export const options = {
  vus: 20,
  duration: "2m",
  thresholds: {
    "http_req_duration{name:GetUsers}": ["p(95)<200"],
    "http_req_duration{name:CreateOrder}": ["p(95)<500"],
  },
};

export default function () {
  group("Browse users", () => {
    const res = http.get("http://localhost:3000/api/users", {
      tags: { name: "GetUsers" },
    });
    check(res, { "users 200": (r) => r.status === 200 });
  });

  sleep(1);

  group("Create order", () => {
    const payload = JSON.stringify({
      userId: 1,
      items: [{ productId: 42, quantity: 2 }],
    });

    const res = http.post("http://localhost:3000/api/orders", payload, {
      headers: { "Content-Type": "application/json" },
      tags: { name: "CreateOrder" },
    });

    check(res, {
      "order created": (r) => r.status === 201,
      "has order id": (r) => r.json("id") !== undefined,
    });
  });

  sleep(2);
}

The tags and group features let you set separate thresholds for different endpoints and see per-endpoint metrics in the output.

Handling Authentication

Many APIs require authentication. Use the setup function to get a token once, then share it across all VUs:

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

export function setup() {
  const loginRes = http.post(
    "http://localhost:3000/api/auth/login",
    JSON.stringify({ email: "test@example.com", password: "password123" }),
    { headers: { "Content-Type": "application/json" } }
  );

  const token = loginRes.json("token");
  return { token };
}

export default function (data) {
  const res = http.get("http://localhost:3000/api/profile", {
    headers: { Authorization: `Bearer ${data.token}` },
  });

  check(res, { "profile 200": (r) => r.status === 200 });
}

The setup function runs once before the test starts. Its return value is passed to every iteration of the default function.

Parameterizing with Shared Data

For realistic tests, use different data for each request:

import http from "k6/http";
import { SharedArray } from "k6/data";

const users = new SharedArray("users", function () {
  return JSON.parse(open("./test-users.json"));
});

export default function () {
  const user = users[__VU % users.length]; // rotate through users
  const res = http.get(`http://localhost:3000/api/users/${user.id}`);
}

SharedArray loads data once and shares it across all VUs efficiently.

Types of Load Tests

k6 supports several load testing patterns:

Smoke test: 1-2 VUs for 1 minute. Verifies the script works and the system is alive.

export const options = { vus: 1, duration: "1m" };

Load test: Normal expected load. Verifies the system handles typical traffic.

export const options = {
  stages: [
    { duration: "5m", target: 100 },
    { duration: "10m", target: 100 },
    { duration: "5m", target: 0 },
  ],
};

Stress test: Beyond normal load. Finds the breaking point.

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: "5m", target: 0 },
  ],
};

Spike test: Sudden burst of traffic. Tests auto-scaling and recovery.

export const options = {
  stages: [
    { duration: "10s", target: 500 },
    { duration: "1m", target: 500 },
    { duration: "10s", target: 0 },
  ],
};

Running k6 in CI/CD

Add k6 to your pipeline with thresholds as quality gates:

# .github/workflows/load-test.yml
name: Load Test
on:
  pull_request:
    branches: [main]

jobs:
  k6:
    runs-on: ubuntu-latest
    services:
      app:
        image: myapp:latest
        ports:
          - 3000:3000
    steps:
      - uses: actions/checkout@v4
      - uses: grafana/k6-action@v0.3.1
        with:
          filename: tests/load/smoke.js

Start with smoke tests in CI (fast, low VUs) and run full load tests on a schedule.

Sending Results to Grafana

k6 integrates with Grafana Cloud and InfluxDB for visualizing results over time:

k6 run --out influxdb=http://localhost:8086/k6 load-test.js

This lets you track performance trends across releases and catch gradual regressions.

Key Takeaways

k6 makes load testing accessible with JavaScript scripts and a fast Go runtime. Start with smoke tests to validate your scripts, then graduate to staged load tests, stress tests, and spike tests. Use thresholds to define pass/fail criteria and integrate k6 into your CI pipeline. The goal is not to generate the most load possible — it is to find your system’s limits and fix bottlenecks before users encounter them.