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.
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.)
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.
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/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
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
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);
}
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
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", };
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%
},
};
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"],
},
};
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);
}
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
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
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}` },
});
}
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
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);
}
| 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
// config/options.ts
import { Options } from "k6/options";
export const options: Options = {
vus: 10,
duration: "30s",
};
// 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;
}
// 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...
}
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:
Top comments (0)