Introduction to Modern Load Testing with Grafana K6
Grafana K6 is an open-source load testing tool designed with developers in mind. Unlike traditional load testing tools that require specialized knowledge or complex setups, K6 takes a code-first approach using JavaScript, making it accessible to developers already familiar with modern programming practices.
The tool emerged from the need for a more developer-friendly load testing solution and was later acquired by Grafana Labs in 2021, integrating it into their observability ecosystem. This integration provides powerful capabilities for not just generating load but also visualizing and analyzing the results.
At its core, K6 helps you understand how your application performs under various levels of simulated user traffic. This is critical for identifying performance bottlenecks, determining system capacity limits, and ensuring your application remains responsive under expected (or unexpected) load conditions.
Let's get started!
Why choose K6 for load testing?
K6 uses JavaScript as its scripting language, but with a purpose-built runtime optimized for load testing rather than a browser engine. This makes writing tests intuitive for developers while ensuring the tool can efficiently generate significant load with minimal resource consumption. The JavaScript API feels familiar to web developers, but doesn't carry the overhead of a full browser, allowing it to create much higher load with fewer resources.
Tests are written in code, can be version-controlled, and integrate seamlessly with CI/CD pipelines. This allows teams to incorporate performance testing directly into their development process rather than treating it as a separate activity. When your load tests live alongside your application code, they evolve together, ensuring your performance requirements remain current.
The tool strikes an excellent balance between simplicity and power. Basic tests can be written in just a few lines of code, but the tool scales to support complex scenarios with realistic user behaviors, data parameterization, and distributed execution. You can start with simple endpoint testing and gradually build up to complex user journey simulations as your testing needs mature.
Being part of the Grafana ecosystem means K6 works well with other observability tools like Prometheus, and Grafana dashboards, enabling comprehensive analysis of test results alongside other system metrics.
This integration is particularly valuable because it allows you to correlate load test results with the actual behavior of your system under that load, offering deeper insights into performance bottlenecks.
Installing K6
Before writing your first test, you'll need to install K6. The installation process is straightforward across different operating systems:
For macOS users with Homebrew, installation is as simple as running:
brew install k6
Windows users with Chocolatey can install K6 using:
choco install k6
Linux users on Debian/Ubuntu systems can use apt:
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb 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
If you prefer using Docker, you can pull the K6 image:
docker pull grafana/k6
After installation, verify it's working correctly by running:
k6 version
You should see output that includes the version number along with some build information:
k6 v0.57.0 (commit/50afd82c18, go1.23.6, linux/amd64)
If you encounter any issues with installation, the K6 documentation provides troubleshooting guidance specific to each platform.
Writing your first K6 test script
Let's create a simple test script to understand the basic structure. Create a
file named first-test.js
with the following content:
import { check, sleep } from "k6";
import http from "k6/http";
// Test configuration - defines how the test will be executed
export const options = {
vus: 10, // Number of Virtual Users (simulated users)
duration: "30s", // How long the test should run
};
// The default function that k6 will call for each Virtual User
export default function () {
// Send an HTTP GET request to the URL
const response = http.get("https://test.k6.io");
// Verify that the response was successful
check(response, {
"status is 200": (r) => r.status === 200,
"page contains welcome text": (r) =>
r.body.includes(
"Collection of simple web-pages suitable for load testing.",
),
});
// Wait for a random time between 1 and 5 seconds before the next iteration
sleep(Math.random() * 4 + 1);
}
This script demonstrates several fundamental concepts in K6. At the top, we
import the necessary modules from K6's built-in libraries. The http
module
provides functions for making HTTP requests, while check
and sleep
are
utility functions for assertions and introducing delays respectively.
The options
object defines the parameters for our test execution. Here, we
specify two key parameters: the number of virtual users (vus) that will
concurrently execute our test script, and the duration for which the test should
run. In this case, we're simulating 10 concurrent users accessing our target
system for 30 seconds.
The default export function is where the magic happens. This function contains the code that each virtual user will execute, potentially multiple times during the test duration. In our example, each virtual user will:
- Make an HTTP GET request to the test website
- Verify the response meets our expectations using checks
- Wait for a random period before repeating the process
The check
function is particularly important as it allows us to assert
conditions about the response. Unlike unit testing, checks in K6 don't terminate
test execution if they fail; instead, they record the success rate of
assertions, which you can later analyze to understand how often your application
behaved as expected.
The sleep
function simulates user think time – the delay between actions that
real users would naturally have. Without sleep, K6 would hammer your system with
requests as fast as possible, which rarely reflects realistic usage patterns.
The random sleep duration creates more natural and varied traffic patterns.
Running your first load test
To execute the test, simply run the following command in your terminal:
k6 run first-test.js
When you run this command, K6 interprets your script, creates the specified number of virtual users, and starts executing the default function according to your configuration. The console will display real-time metrics as the test progresses, followed by a summary once the test completes.
The output will look something like this:
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: first-test.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
running (0m30.0s), 10/10 VUs, 73 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs 30s
✓ status is 200
✓ page contains welcome text
checks.........................: 100.00% ✓ 146 ✗ 0
data_received..................: 671 kB 22 kB/s
data_sent......................: 11 kB 383 B/s
http_req_blocked...............: avg=4.14ms min=2µs med=8µs max=105.87ms p(90)=15µs p(95)=31.44ms
http_req_connecting............: avg=2.49ms min=0s med=0s max=64.06ms p(90)=0s p(95)=20.14ms
http_req_duration..............: avg=138.12ms min=125.11ms med=135.45ms max=204.4ms p(90)=153.36ms p(95)=162.95ms
{ expected_response:true }...: avg=138.12ms min=125.11ms med=135.45ms max=204.4ms p(90)=153.36ms p(95)=162.95ms
http_req_failed................: 0.00% ✓ 0 ✗ 73
http_req_receiving.............: avg=5.5ms min=0.19ms med=5.29ms max=17.37ms p(90)=8.93ms p(95)=9.79ms
http_req_sending...............: avg=0.04ms min=0.01ms med=0.03ms max=0.21ms p(90)=0.06ms p(95)=0.08ms
http_req_tls_handshaking.......: avg=1.58ms min=0s med=0s max=41.55ms p(90)=0s p(95)=10.78ms
http_req_waiting...............: avg=132.58ms min=124.32ms med=129.98ms max=203.69ms p(90)=144.38ms p(95)=153.51ms
http_reqs......................: 73 2.432575/s
iteration_duration.............: avg=4.09s min=1.13s med=4.16s max=6.2s p(90)=5.16s p(95)=5.54s
iterations.....................: 73 2.432575/s
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10
This rich output provides comprehensive information about your test run. At the top, we can see a summary of the test configuration and execution: 10 virtual users ran for 30 seconds, completing 73 iterations of our script with no interruptions.
The checks section shows that both of our assertions passed in all iterations – the status code was always 200, and the expected welcome text was always present in the response body. This is a good indication that our target system handled the load successfully.
The metrics section provides detailed timing information for every part of the HTTP request/response cycle:
http_req_blocked
: Time spent waiting before the request could be sent (affected by connection pool availability)http_req_connecting
: Time spent establishing TCP connectionshttp_req_duration
: Total time for the request, from sending to receiving the response (this is often the most important metric)http_req_receiving
: Time spent receiving the response datahttp_req_waiting
: Time spent waiting for the server to process the request (also known as "time to first byte")
For each metric, K6 provides statistical information including the average,
minimum, maximum, median, and percentile values (p90
, p95
).
The percentile values are particularly useful for understanding the experience
of most users – for example, the p95
value of 162.95ms for http_req_duration
means that 95% of requests completed in less than 162.95ms.
The throughput metrics show we achieved about 2.43 requests per second with our 10 virtual users, and each iteration (including the sleep time) took an average of 4.09 seconds.
Understanding your test results
K6 provides several categories of metrics that help you understand application performance in depth. Let's explore what these results are telling us about our application:
The http_req_duration
metric is usually the most critical from a user
experience perspective, as it represents the total time from request initiation
to response completion. In our test, the average duration was 138.12ms, with 95%
of requests completing in under 162.95ms. This is generally considered good
performance for web applications, where response times under 200ms feel
instantaneous to users.
The http_req_waiting
metric (132.58ms average) represents the server
processing time – the period after the request is fully sent and before the
response starts arriving. The fact that this makes up most of the total request
duration indicates that network transmission time was minimal, and most of the
time was spent on server-side processing.
The iterations
metric tells us how many complete test iterations were
executed. Each iteration represents one simulated user journey through your
application. With 73 iterations completed by 10 users over 30 seconds, we can
calculate that each user completed about 7.3 iterations on average.
The checks
metric confirms that our application responded as expected in all
cases. In a real-world scenario, you might have more extensive checks to verify
not just that the response has a 200 status code, but that it contains the
expected data structures, has acceptable response sizes, or meets other business
requirements.
What's notably absent from our test results is any indication of errors or
failed requests – the http_req_failed
metric shows 0%. This suggests that our
target system handled the simulated load without issues. In a real performance
testing scenario, one of your goals would be to gradually increase the load
until you start seeing errors or unacceptable response times, which would help
identify the system's capacity limits.
Customizing your load profile
Our first example used a simple load profile: 10 users for 30 seconds. While this is fine for initial testing, real-world traffic patterns are rarely constant.
Users often arrive in waves, with peak periods throughout the day. K6 allows you to define more realistic load patterns using stages. Let's modify our script to simulate a gradual ramp-up, sustained load, and ramp-down:
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 20 }, // Ramp up to 20 users over 1 minute
{ duration: '3m', target: 20 }, // Stay at 20 users for 3 minutes
{ duration: '1m', target: 0 }, // Ramp down to 0 users over 1 minute
],
thresholds: {
'http_req_duration': ['p(95)<250'], // 95% of requests must complete within 250ms
'checks': ['rate>0.95'], // 95% of checks must pass
},
};
export default function() {
const response = http.get('https://test.k6.io');
check(response, {
'status is 200': (r) => r.status === 200,
'page contains welcome text': (r) => r.body.includes('Welcome to the k6.io test site!'),
});
sleep(Math.random() * 4 + 1);
}
This updated script introduces two important concepts that make our load test more realistic and meaningful:
The stages
array replaces our simple vus
and duration
configuration,
defining a variable load pattern that simulates how real traffic might behave.
In this example, we start with 0 virtual users and gradually increase to 20 over
the first minute, maintain 20 users for three minutes to observe system behavior
under sustained load, and then gradually decrease back to 0 over the final
minute. This approach helps identify issues that might only appear during
transitions between load levels, such as connection pool exhaustion during rapid
traffic increases.
The thresholds
object establishes pass/fail criteria for our performance test
based on metrics we care about. Thresholds transform k6 from a tool that merely
collects performance data into one that can make judgments about whether your
application meets its performance requirements. In this case, we've defined two
thresholds:
- 95% of requests must complete within 250 milliseconds.
- At least 95% of our checks must pass.
If either of these conditions is not met, K6 will exit with a non-zero status code, making it easy to integrate with CI/CD pipelines. This allows you to automatically fail builds or deployments if performance degrades beyond acceptable levels.
When you run this updated script, K6 will not only execute the test but also evaluate the results against your thresholds, providing a clear indication of whether your application meets its performance requirements:
k6 run enhanced-test.js
The output will now include threshold evaluation results, showing whether each threshold passed or failed. This makes it much easier to quickly determine if your application's performance is acceptable without having to analyze the detailed metrics manually.
Simulating more realistic user journeys
Real users rarely access just a single page on your application. They navigate through multiple pages, fill out forms, submit data, and interact with various elements.
To simulate this more realistic behavior, let's expand our test to represent a complete user journey:
import { check, group, sleep } from "k6";
import http from "k6/http";
export const options = {
vus: 10,
duration: "1m",
};
export default function () {
// Group related requests together for better organization
group("Visit homepage", () => {
const homeResponse = http.get("https://test.k6.io/");
check(homeResponse, {
"homepage status is 200": (r) => r.status === 200,
"homepage has correct title": (r) =>
r.body.includes("<title>Demo website for load testing</title>"),
});
sleep(Math.random() * 2 + 1);
});
let csrftoken;
// Navigate to my messages page
group("Visit my messages page", () => {
const messageResponse = http.get("https://test.k6.io/my_messages.php");
check(messageResponse, {
"message status is 200": (r) => r.status === 200,
"message page contains form": (r) =>
r.body.includes("<h2>Unauthorized</h2>"),
});
csrftoken = messageResponse
.html()
.find("input[name=csrftoken]")
.first()
.attr("value");
sleep(Math.random() * 2 + 1);
});
// Submit the form
group("Submit form", () => {
const formData = {
login: "admin",
password: "123",
redir: 1,
csrftoken,
};
const submitResponse = http.post("https://test.k6.io/login.php", formData);
check(submitResponse, {
"form submission was successful": (r) =>
r.status === 200 && r.body.includes("Welcome, admin!"),
});
sleep(Math.random() * 3 + 2);
});
}
This script introduces several new concepts that help create more realistic and organized load tests:
The group
function allows us to organize related requests together. This not
only makes the script more readable but also ensures that the test results are
organized in a meaningful way. When you run this test, k6 will report metrics
separately for each group, making it easier to identify which part of the user
journey might be experiencing performance issues.
We're now using multiple HTTP methods to interact with the application. The initial navigation uses GET requests, while the form submission uses a POST request with form data. This better represents how real users interact with web applications, where data is both retrieved from and submitted to the server.
Different parts of the user journey have different think times. Users typically spend more time reviewing a form and filling it out than they do scanning a homepage, so we've adjusted our sleep times accordingly. The form submission is followed by a slightly longer sleep time, as users often take a moment to read confirmation messages.
The form submission demonstrates how to send data to the server. We create a
simple JavaScript object with the form fields and values, and pass it as the
second argument to the http.post
function. K6 automatically handles
serializing this data and setting the appropriate content type.
By creating a script that simulates a complete user journey, we gain several advantages:
- We test the performance of all parts of our application, not just a single endpoint
- We can identify bottlenecks at specific steps in the user journey
- We create more realistic load patterns that better represent how users actually interact with our application
- We can verify that the application functions correctly throughout the entire process, not just that it responds quickly
When you run this script, the output will be organized by groups, making it easy to see which part of the user journey might be experiencing performance issues:
k6 run user-journey.js
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: user-journey.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m30s max duration (incl. graceful stop):
* default: 10 looping VUs for 1m0s (gracefulStop: 30s)
█ Visit homepage
✓ homepage status is 200
✓ homepage has correct title
█ Visit my messages page
✓ message status is 200
✓ message page contains form
█ Submit form
✓ form submission was successful
. . .
In the results, you'll see metrics broken down by group, allowing you to compare the performance of different parts of your application. For example, you might find that while the homepage loads quickly, the form submission process is significantly slower, indicating a potential area for optimization.
Visualizing test results
While the console output provides valuable information, visualization can help identify patterns and issues more easily. K6 offers several options for visualizing your test results, from simple built-in tools to sophisticated integrations with the Grafana ecosystem.
A simple way to generate a visual report is by using the built-in JSON output and the K6 HTML report tool. First, run your test and save the results as JSON:
k6 run --out json=results.json first-test.js
This command executes your test script and saves all metrics and events to a JSON file. Once the test completes, you can analyze the results through an external service.
{"type":"Metric","data":{"name":"http_reqs","type":"counter","contains":"default","thresholds":[],"submetrics":null},"metric":"http_reqs"}
{"metric":"http_reqs","type":"Point","data":{"time":"2025-03-03T10:08:57.566100919+01:00","value":1,"tags":{"expected_response":"true","group":"","method":"GET","name":"https://test.k6.io","proto":"HTTP/1.1","scenario":"default","status":"200","tls_version":"tls1.3","url":"https://test.k6.io"}}}
{"type":"Metric","data":{"name":"http_req_duration","type":"trend","contains":"time","thresholds":[],"submetrics":[{"name":"http_req_duration{expected_response:true}","suffix":"expected_response:true","tags":{"expected_response":"true"}}]},"metric":"http_req_duration"}
. . .
For more sophisticated visualization, you can leverage K6's integration with the Grafana . This approach is especially valuable for long-running tests or when you want to correlate load test results with other system metrics.
First, you'll need to set up InfluxDB (to store the time-series data) and Grafana (to visualize it). The easiest way to do this is using Docker:
docker compose up -d influxdb grafana
This assumes you have a compose.yaml
file that defines these services. Once
these services are running, you can execute your test with InfluxDB as the
output:
k6 run --out influxdb=http://localhost:8086/k6 first-test.js
With this setup, K6 will stream metrics to InfluxDB in real-time as the test runs. You can then create dashboards in Grafana to visualize these metrics alongside other system data such as CPU usage, memory consumption, or database performance.
The Grafana approach is particularly powerful because it allows you to:
- Monitor test results in real-time as the test is running
- Create persistent dashboards that can be used across multiple test runs
- Compare results from different test runs to track performance changes over time
- Correlate load test metrics with system metrics to identify root causes of performance issues
For teams that are serious about performance testing, investing in a proper visualization setup can dramatically improve the insights gained from your load tests and make it easier to communicate results to stakeholders.
Next steps for your K6 journey
As you become comfortable with the basics of K6, there are four key areas to explore next:
First, look into modular test organization. Breaking your tests into reusable JavaScript modules helps manage complex test scenarios and promotes code sharing across your team. This approach is particularly valuable as your test suite grows beyond simple scripts.
Second, experiment with data parameterization by feeding external data (like CSV files with test credentials or API payloads) into your tests. This makes your load tests more realistic by varying the data used across virtual users rather than having everyone perform identical actions.
Third, consider integrating K6 into your CI/CD pipeline. By automatically running performance tests on each build, you can catch performance regressions early, before they reach production. Most CI systems can be configured to fail builds when K6 thresholds aren't met.
Finally, when you need to simulate very high loads, explore K6 Cloud or distributed execution. These approaches allow you to generate traffic levels far beyond what a single machine can produce, letting you test how your application performs under extreme conditions.
Each of these areas builds naturally on the foundations covered in this guide, allowing you to gradually develop more sophisticated load testing capabilities as your application and performance testing needs mature.
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github