# Introduction to Modern Load Testing with Grafana K6

[Grafana K6](https://k6.io/) 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!

[ad-logs]

## 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](https://betterstack.com/community/guides/monitoring/prometheus/), and [Grafana dashboards](https://betterstack.com/community/guides/monitoring/visualize-prometheus-metrics-grafana/), 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](https://grafana.com/docs/k6/latest/set-up/install-k6/). The
installation process is straightforward across different operating systems:

For macOS users with Homebrew, installation is as simple as running:

```command
brew install k6
```

Windows users with Chocolatey can install K6 using:

```command
choco install k6
```

Linux users on Debian/Ubuntu systems can use apt:

```command
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
```

```command
echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
```

```command
sudo apt-get update
```

```command
sudo apt-get install k6
```

If you prefer using Docker, you can pull the K6 image:

```command
docker pull grafana/k6
```

After installation, verify it's working correctly by running:

```command
k6 version
```

You should see output that includes the version number along with some build
information:

```text
[output]
k6 v0.57.0 (commit/50afd82c18, go1.23.6, linux/amd64)
```

If you encounter any issues with installation, the
[K6 documentation](https://grafana.com/docs/k6/latest/set-up/install-k6/)
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:

```javascript
[label first-test.js]
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:

1. Make an HTTP GET request to the test website
2. Verify the response meets our expectations using checks
3. 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

![Grafana k6 run](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/25121d1e-19d8-4891-b8bc-e3e79721b600/lg1x =2796x1988)

To execute the test, simply run the following command in your terminal:

```command
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:

```text
[output]
          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .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 connections
- `http_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 data
- `http_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:

```javascript
[label first-test.js]
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:

1. 95% of requests must complete within 250 milliseconds.
2. 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:

```command
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

![Screenshot From 2025-03-03 10-08-13.png](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/703fbe50-c5c7-4a6d-120b-aec0bee99100/public =2998x2162)

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:

```javascript
[label user-journey.js]
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:

1. We test the performance of all parts of our application, not just a single
   endpoint
2. We can identify bottlenecks at specific steps in the user journey
3. We create more realistic load patterns that better represent how users
   actually interact with our application
4. 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:

```command
k6 run user-journey.js
```

```text
[output]

         /\      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:

```command
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.

```json
[label results.json]
{"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:

```command
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:

```command
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:

1. Monitor test results in real-time as the test is running
2. Create persistent dashboards that can be used across multiple test runs
3. Compare results from different test runs to track performance changes over
   time
4. 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.
