DEV Community

Cover image for A Practical Guide to Load Testing with k6
gr1m0h
gr1m0h

Posted on

A Practical Guide to Load Testing with k6

Introduction

I've been using k6 for load testing since 2018, back when I was also contributing to the official documentation. You can read about my early work with k6 in this blog post:

https://tech-blog.optim.co.jp/entry/2019/01/15/173000

With the release of k6 1.0 in May 2025—featuring native TypeScript support and semantic versioning—I decided it was time to revisit the tool and share an updated guide covering everything from basics to advanced usage.

This article focuses on load testing APIs and web applications. Load testing plays a crucial role in infrastructure capacity planning: an iterative process that ensures you have enough capacity and redundancy to meet anticipated demand. By running load tests, you can assess your current system's capacity, identify bottlenecks, understand scalability characteristics, and ultimately design a configuration that meets your performance requirements.

https://k6.io/

What is k6?

k6 is an open-source load testing tool developed by Grafana Labs. Built in Go, it lets you write test scripts in JavaScript or TypeScript.

Here's what makes k6 stand out:

  • Lightweight and fast: Virtual Users (VUs) run as goroutines, enabling massive load generation from a single machine
  • Developer-friendly: Write scripts in JavaScript/TypeScript—no steep learning curve for web developers
  • Broad protocol support: HTTP/HTTPS, WebSocket, gRPC, browser testing, and more
  • Grafana Cloud integration: Scale your tests and visualize results with Grafana Cloud k6

k6 1.0, released in May 2025, introduced native TypeScript support, semantic versioning, and the official browser module.

For full details, check out the documentation:

https://grafana.com/docs/k6/latest/

My History with k6

                                                                                              A lot has changed in the k6 ecosystem since I started using it in 2018. Here's a brief look at how we got here. (Feel free to skip ahead if history isn't your thing.)
Enter fullscreen mode Exit fullscreen mode

k6 was created by Load Impact, a Swedish company that started development in 2016 and open-sourced it in February 2017.

https://grafana.com/blog/grafana-k6-one-year-later-lessons-learned-after-an-acquisition/

.

Before k6, Load Impact's SaaS product used Lua for scripting. According to their developer blog, LuaJIT VM was blazing fast and resource-efficient—great for simulating many virtual users on limited hardware. But Lua was relatively obscure, and most developers weren't familiar with it. So they built k6 from the ground up with JavaScript to make it more accessible. That means k6 has always been JavaScript-based; you can't use Lua with it.

https://web.archive.org/web/20240314164107/https://k6.io/blog/creating-k6/

Back when I started, the ecosystem consisted of the k6 CLI plus two cloud services: "Load Impact" for distributed test execution and "Load Impact Insights" for visualization and analysis. The Lua-based Load Impact v3 was still available alongside the JavaScript version, complete with migration guides.

On February 24, 2020, Load Impact rebranded to "k6" and renamed the cloud service to "k6 Cloud." (The legal entity, Load Impact AB, stayed the same—this was purely a product rebrand.)

https://community.grafana.com/t/load-impact-is-now-k6/95571

Then on June 17, 2021, Grafana Labs announced the acquisition of Load Impact at GrafanaCONline 2021.

https://grafana.com/about/press/2021/06/17/grafana-labs-brings-modern-open-source-load-testing-to-observability-with-acquisition-of-k6/

https://www.globenewswire.com/en/news-release/2021/06/17/2249273/0/en/Grafana-Labs-Brings-Modern-Open-Source-Load-Testing-to-Observability-with-Acquisition-of-k6.html

k6 Cloud has since been integrated into Grafana Cloud as "Grafana Cloud k6." The open-source philosophy remains intact, and k6 now benefits from deep integration with Grafana's observability stack.

https://grafana.com/products/cloud/k6/

On May 7, 2025, k6 1.0 reached general availability at GrafanaCON 2025. After nearly 9 years of development, k6 is now a mature, enterprise-ready tool with native TypeScript support, semantic versioning, and a stable API surface.

https://grafana.com/about/press/2025/05/07/grafana-labs-demonstrates-open-source-leadership-at-grafanacon-2025/

https://grafana.com/blog/2025/05/07/grafana-k6-1.0-release/

Installation

On macOS, install via Homebrew. You can also use asdf-vm or mise:

# Homebrew
brew install k6
# asdf-vm
asdf plugin add k6
asdf install k6 latest
# mise
mise use -g k6@latest
Enter fullscreen mode Exit fullscreen mode

Fun fact: the asdf plugin that asdf-vm and mise use under the hood? I created it.

https://github.com/gr1m0h/asdf-k6

I built this plugin back in 2019, and I'm glad to see it's still in use today.

Creating Test Scripts

Generate a template with k6 new script.ts. As of k6 1.0, TypeScript is natively supported—no transpilation needed. Honestly, this is the update I'm most excited about.

For IDE autocompletion, install the type definitions:

npm install --save-dev @types/k6
Enter fullscreen mode Exit fullscreen mode

Here's the basic structure of a test script:

import http, { Response } from "k6/http";
import { check, sleep } from "k6";
import { Options } from "k6/options";

// Test configuration
export const options: Options = {
  vus: 10,
  duration: "30s",
  thresholds: {
    http_req_duration: ["p(95)<500"],
    http_req_failed: ["rate<0.01"],
  },
};

// Test logic executed repeatedly by each VU
export default function (): void {
  const res: Response = http.get(
    "https://jsonplaceholder.typicode.com/todos/1",
  );
  check(res, {
    "status is 200": (r: Response): boolean => r.status === 200,
  });
  sleep(1);
}
Enter fullscreen mode Exit fullscreen mode

Running Tests

Run your test with k6 run script.ts. The output shows threshold results, check pass rates, and response time statistics:

  █ THRESHOLDS
    http_req_duration                                                                                 ✓ 'p(95)<500' p(95)=77.37ms

  █ TOTAL RESULTS
    checks_succeeded...: 100.00% 574 out of 574
    http_req_duration..............: avg=56.88ms p(95)=77.37ms
    http_reqs......................: 287    9.403621/s
Enter fullscreen mode Exit fullscreen mode

Load Patterns

k6 offers flexible load configuration. Here are three common patterns.

Fixed Load

Set a constant number of VUs for a fixed duration:

export const options: Options = {                                                                   vus: 10,
  duration: "30s",                                                                                };
Enter fullscreen mode Exit fullscreen mode

Ramping Load

Simulate realistic traffic patterns with the stages option:

export const options = {
  stages: [
    { duration: "30s", target: 20 }, // Ramp up to 20 VUs over 30 seconds
    { duration: "1m", target: 20 }, // Stay at 20 VUs for 1 minute
    { duration: "30s", target: 0 }, // Ramp down to 0 VUs over 30 seconds
  ],
};                                                                                                ```


                                                                                                  This creates a classic warm-up → steady state → cool-down pattern.
                                                                                                  ### Scenarios

For complex load patterns, use `scenarios` to manage multiple test patterns in one script:



```javascript
export const options = {
  scenarios: {
    // Send requests at a constant rate
    constant_request_rate: {
      executor: "constant-arrival-rate",
      rate: 100,
      timeUnit: "1s",
      duration: "1m",
      preAllocatedVUs: 50,
    },
    // Gradually increase VUs
    ramping_vus: {
      executor: "ramping-vus",
      startVUs: 0,                                                                                      stages: [
        { duration: "30s", target: 50 },                                                                  { duration: "1m", target: 50 },
        { duration: "30s", target: 0 },                                                                 ],
    },                                                                                              },
};                                                                                                ```



k6 provides several executors to match your testing goals:

https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/
                                                                                                  ## Setting Thresholds

Define pass/fail criteria with the `thresholds` option:



```javascript
export const options = {
  thresholds: {
    http_req_duration: ["p(95)<500"], // 95th percentile under 500ms
    http_req_failed: ["rate<0.01"], // Error rate under 1%
  },
};
Enter fullscreen mode Exit fullscreen mode

If any threshold fails, k6 exits with a non-zero code—perfect for CI/CD pipelines.

https://pkg.go.dev/go.k6.io/k6/errext/exitcodes

You can set multiple conditions:

export const options = {
  thresholds: {
    http_req_duration: [
      "p(95)<500", // 95th percentile under 500ms
      "p(99)<1000", // 99th percentile under 1000ms                                                     "avg<300", // Average under 300ms
    ],                                                                                                http_req_failed: ["rate<0.01"],
  },
};
Enter fullscreen mode Exit fullscreen mode

Checks and Groups

Use check to validate responses and group to organize related requests. Unlike thresholds, failed checks don't stop the test:

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

export default function () {
group("API Tests", function () {
const res = http.get("https://quickpizza.grafana.com");
check(res, {
"status is 200": (r) => r.status === 200,
"response time < 500ms": (r) => r.timings.duration < 500,
});
});
}




**When to use which?** I use thresholds for overall pass/fail decisions and checks for detailed response validation. Checks are assertions that keep running even when they fail—great for debugging and granular verification.

## Test Lifecycle

k6 tests have four phases:



```javascript
import http from "k6/http";

// 1. init: Runs once per VU at startup
const BASE_URL = "https://jsonplaceholder.typicode.com";

// 2. setup: Runs once before the test starts
export function setup() {
  // Fetch existing data to determine the target ID for testing
  const res = http.get(`${BASE_URL}/posts/1`);
  return { postId: res.json("id") };
}

// 3. default: Runs repeatedly by each VU (main test logic)
export default function (data) {
  http.get(`${BASE_URL}/posts/${data.postId}`);
}

// 4. teardown: Runs once after the test ends
export function teardown(data) {
  console.log("Test completed with postId:", data.postId);
}
Enter fullscreen mode Exit fullscreen mode

Data from setup is passed to both default and teardown. Use this for fetching auth tokens or preparing test data.

Visualizing Results

Web Dashboard

k6 includes a built-in web dashboard:

k6 run --out web-dashboard script.js
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:5665 during the test to monitor results in real time.

JSON/CSV Output

Export results to files:

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

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

# Summary in JSON format
k6 run --summary-export=summary.json script.js
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Access environment variables via __ENV. Useful for switching configurations between environments:

const BASE_URL = __ENV.BASE_URL || "https://quickpizza.grafana.com";
const API_KEY = __ENV.API_KEY;

export default function () {
  const res = http.get(`${BASE_URL}/api/pizza`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
}
Enter fullscreen mode Exit fullscreen mode
BASE_URL=https://quickpizza.grafana.com API_KEY=xxx k6 run script.ts
# Or use the -e flag
k6 run -e BASE_URL=https://quickpizza.grafana.com script.ts
Enter fullscreen mode Exit fullscreen mode

Custom Metrics

Beyond built-in metrics, you can define your own:

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

// Custom metrics definition
const apiCalls = new Counter("api_calls");
const apiDuration = new Trend("api_duration");
const apiSuccess = new Rate("api_success");
const activeUsers = new Gauge("active_users");

export default function () {
  const res = http.get("https://quickpizza.grafana.com");

  apiCalls.add(1);
  apiDuration.add(res.timings.duration);
  apiSuccess.add(res.status === 200);
  activeUsers.add(10);
}
Enter fullscreen mode Exit fullscreen mode
Type Use Case
Counter Cumulative values (request count)
Trend Statistical values (response time)
Rate Ratios (success rate)
Gauge Point-in-time values (active users)

Organizing Tests Across Multiple Files

As tests grow, split them into modules:

tests/
├── config/
│   └── options.ts
├── utils/
│   └── helpers.ts
├── scenarios/
│   ├── login.ts
│   └── checkout.ts
└── main.ts
Enter fullscreen mode Exit fullscreen mode
// config/options.ts
import { Options } from "k6/options";

export const options: Options = {
  vus: 10,
  duration: "30s",
};
Enter fullscreen mode Exit fullscreen mode
// scenarios/login.ts
import http from "k6/http";
import { check } from "k6";

export function login(baseUrl: string, username: string, password: string) {
  const payload = JSON.stringify({ username, password });
  const params = { headers: { "Content-Type": "application/json" } };
  const res = http.post(`${baseUrl}/posts`, payload, params);
  check(res, { "login success": (r) => r.status === 201 });
  // Return token for actual login API
  const body = res.json();
  return body?.id ? `mock-token-${body.id}` : null;
}
Enter fullscreen mode Exit fullscreen mode
// main.ts
import { options } from "./config/options.ts";
import { login } from "./scenarios/login.ts";

export { options };

const BASE_URL = __ENV.BASE_URL || "https://jsonplaceholder.typicode.com";

export default function () {
  const token = login(BASE_URL, "user", "pass");
  // Continue with authenticated requests...
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

That covers the fundamentals of k6. With version 1.0's native TypeScript support and semantic versioning, k6 now offers a stable, enjoyable development experience.

This guide focused on standalone k6 usage, but in production you'll likely want to integrate with Grafana Cloud k6 and your CI/CD pipelines. I plan to cover those topics in a future post.

For more details, check out the official docs:

https://grafana.com/docs/k6/latest/

https://github.com/grafana/k6

Top comments (0)