Load testing with Grafana k6

Here at GIBE, we focus on building scalable, resilient web applications that are accessible and performant. While tools like Cypress are great for automated end-to-end testing of functionality, you don't want to wait until a site goes live to find out if it can handle a lot of users. So, what kind of tool could you use to help simulate that kind of traffic? Enter Grafana k6!

What is Grafana k6?

We've recently started load testing using Grafana k6 to simulate higher amounts of traffic to our sites before putting changes live.

Grafana k6 is a lightweight, open-source command-line interface (CLI) tool that lets you simulate traffic to websites and APIs. Although Grafana do have a service that allows you to run tests from their platform and see results via their web interface, you don't interact with it through a web dashboard or a complex desktop app. Instead, you write your test scenarios as standard JavaScript files and pass them directly to k6 via your terminal. k6 then handles spinning up virtual users (VUs) to load the site and display the results right back in your console or your CI/CD pipeline.

Installing k6 on Windows

Because our development environment is in Windows, getting k6 running locally is straightforward. You can install it using either a standard Windows installer or via the Windows Package Manager (winget).

Open your PowerShell terminal and run:

winget install grafana.k6

Alternatively, you can download the .msi installer directly from the official k6 documentation.

Important! Once installed, the installer updates your "PATH" environment variables, so you'll need to restart your CLI for the new tool to be available.

To verify the installation worked, open a fresh terminal and run:

k6 version

You should see the version number displayed, something like this:

k6.exe v1.3.0 (commit/5870e99ae8, go1.25.1, windows/amd64)

Creating your first script

k6 scripts are simple JavaScript files that you pass into the k6 command, however, you don't need to create it from scratch. Instead, k6 includes a built-in initialiser that scaffolds a clean, simple starting point.

Navigate to your working directory and run:

k6 new myscript.js

This creates a file named "myscript.js" with single basic request which looks like this:

import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = {
  vus: 10,
  duration: '30s',
};

export default function() {
  let res = http.get('https://quickpizza.grafana.com');
  check(res, { "status is 200": (res) => res.status === 200 });
  sleep(1);
}

The file has 3 broad sections:

  1. Imports: At the top we import the modules we're going to use. "k6/http" allows us to make HTTP requests, "sleep" allows us to pace the test, and "check" allows us to validate the responses.
  2. Options configuration: The options object lets us configure how the test runs. In this example, k6 will spin up 10 Virtual Users (VUs) and run them concurrently for a duration of 30 seconds.
  3. The default function: This is the core of what we'll execute. The code will be run by every VU in a continuous loop for the duration of the test.

Let's break down the default function:

  • It sends a request to a URL and stores the response in the "res" variable.
  • It does a check to see if it was successful by checking that the HTTP status of the response was a 200 OK result.
  • Sleeps for 1 second - this is recommended by Grafana to "help control the load generator and better simulate the traffic pattern of human users".

Running the script

To execute the test, execute the following in the CLI:

k6 run myscript.js

While it is running, you'll get a progress bar along with some basic stats:


         /\      Grafana   /‾‾/
    /\  /  \     |\  __   /  /
   /  \/    \    | |/ /  /   ‾‾\
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/


     execution: local
        script: .\myscript.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 (0m23.9s), 10/10 VUs, 210 complete and 0 interrupted iterations
default   [=============================>--------] 10 VUs  23.9s/30s

Understanding the results

Once the 30 seconds are up, it will stop execution and print a summary table directly to your terminal, which will look something like this:

TOTAL RESULTS

    HTTP
    http_req_duration..............: avg=97.93ms min=84.98ms med=97.66ms max=109.02ms p(90)=99.73ms p(95)=101.55ms
      { expected_response:true }...: avg=97.93ms min=84.98ms med=97.66ms max=109.02ms p(90)=99.73ms p(95)=101.55ms
    http_req_failed................: 0.00%  0 out of 280
    http_reqs......................: 280    9.020976/s

    EXECUTION
    iteration_duration.............: avg=1.1s    min=1.09s   med=1.09s   max=1.33s    p(90)=1.1s    p(95)=1.1s
    iterations.....................: 280    9.020976/s
    vus............................: 6      min=6        max=10
    vus_max........................: 10     min=10       max=10

    NETWORK
    data_received..................: 976 kB 32 kB/s
    data_sent......................: 32 kB  1.0 kB/s




running (0m31.0s), 00/10 VUs, 280 complete and 0 interrupted iterations

While it outputs a lot of granular data, here are the key metrics you need to focus on initially:

  • http_req_duration: The time taken by the requests. The average is what you can see first, however, you should also pay close attention to the p(90) and p(95) (90th and 95th percentiles). These represent the experience of your slowest users and are good indicators of performance under load.
  • http_req_failed: The percentage and number of requests that resulted in a network or HTTP 4xx/5xx error. Ideally, this should stay at 0.00%.
  • http_reqs: The total number of HTTP requests generated by all your virtual users combined during the test.
  • vus and vus_max: Confirms the number of active virtual users during the run.

Scaling up: simulating a real user journey

In reality, users rarely hit a single endpoint. When a user visits a modern website, they request an HTML page, and then the front-end typically fires off several asynchronous API requests to populate the page with data.

In order to get accurate performance stats for both the page load and our API endpoints, we can use two powerful k6 features: "group" (to organise transactions) and "http.batch" (to send multiple requests at the same time, just like a browser does).

Here's how we could simulate a user landing on a product page, which then makes API calls to fetch product recommendations and delivery estimates:

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

export const options = {
    // Define the traffic ramp-up and ramp-down
    stages: [
        { duration: '30s', target: 20 }, // Ramp up to 20 virtual users (VUs)
        { duration: '1m', target: 20 },  // Maintain 20 VUs for 1 minute
        { duration: '10s', target: 0 },  // Ramp down to 0 VUs gracefully
    ,
    thresholds: {
        // By default, k6 doesn't break down the stats by group to prevent clutter.
        // We force it to display by setting a threshold - we set it to an artificially high value
        // that will never realistically fail, allowing us to see the metrics in the output.
        'group_duration{group:::Load Product Page}': ['avg<60000'],
        'group_duration{group:::Fetch Async API Data}': ['avg<60000'],
    }
};

export default function () {
    // Step 1: Simulate the initial page load
    group('Load Product Page', function () {
        let res = http.get('https://shop.myexamplesite.com/products/123');
        check(res, { 'HTML loaded successfully': (r) => r.status === 200 });
    });

    // Simulate client-side processing/rendering time before API calls fire
    sleep(0.5);

    // Step 2: Simulate the front-end fetching async data in parallel
    group('Fetch Async API Data', function () {
        let reqs = {
            'recommendations': {
                method: 'GET',
                url: 'https://shop.myexamplesite.com/api/recommendations/123',
            },
            'delivery': {
                method: 'GET',
                url: 'https://shop.myexamplesite.com/api/delivery/123',
            },
        };

        // http.batch fires these requests concurrently
        let responses = http.batch(reqs);

        check(responses['recommendations'], { 'Recommendations OK': (r) => r.status === 200 });
        check(responses['delivery'], { 'Delivery OK': (r) => r.status === 200 });
    });

    // Simulate user dwell time before they navigate away
    sleep(2);
}

When run, this produces the following stats:

  █ THRESHOLDS

    group_duration{group:::Fetch Async API Data}
    ✓ 'avg<60000' avg=88.34ms

    group_duration{group:::Load Product Page}
    ✓ 'avg<60000' avg=224.39ms


  █ TOTAL RESULTS

    checks_total.......: 87      2.801054/s
    checks_succeeded...: 100.00% 87 out of 87
    checks_failed......: 0.00%   0 out of 87

    ✓ HTML loaded successfully
    ✓ Recommendations OK
    ✓ Delivery OK

    HTTP
    http_req_duration..............: avg=127.18ms min=60.19ms med=86.8ms max=305.63ms p(90)=232.31ms p(95)=246.17ms
      { expected_response:true }...: avg=127.18ms min=60.19ms med=86.8ms max=305.63ms p(90)=232.31ms p(95)=246.17ms
    http_req_failed................: 0.00% 0 out of 87
    http_reqs......................: 87    2.801054/s

    EXECUTION
    iteration_duration.............: avg=2.81s    min=2.76s   med=2.8s   max=2.9s     p(90)=2.86s    p(95)=2.89s
    iterations.....................: 29    0.933685/s
    vus............................: 1     min=1       max=3
    vus_max........................: 3     min=3       max=3

    NETWORK
    data_received..................: 24 MB 762 kB/s
    data_sent......................: 47 kB 1.5 kB/s




running (0m31.1s), 0/3 VUs, 29 complete and 0 interrupted iterations
default ✓ [======================================] 0/3 VUs  30s

The detailed stats are still across all requests, regardless of which URL was called, however, you can see a breakdown of the average request duration between loading the product page and making the asynchronous API calls. In this case, you can see that the initial page load is slower than the subsequent API calls.

Wrapping up

We've covered off the basics of running k6 load tests, but there is so much more to it and more to explore:

  • AI is your friend - k6 is quite a popular tool, so your favourite AI will almost certainly be able to help you with writing k6 tests as there is a lot of examples out there for it to draw on.
  • JavaScript flexibility - because the script itself is just standard JavaScript, you're not limited to a single file. You can reference other files, pull in libraries as well get, and set, data using API calls to do setup before your test run.
  • Multiple scenarios - you can define multiple scenarios and choose to run one or more at the same time.
  • Environment variables - because k6 scripts are executed via the CLI, you can easily pass environmental variables in at runtime. This means you could use this to target different sites, change the test parameters or choose different scenarios.
  • Custom metrics - as we've seen, the metrics you get out of the box with k6 are a really good start, but can be limiting once you start testing more than a single page. However, you can also import and use the k6 Trend, Counter, Rate or Gauge objects to create custom metrics.
  • CI/CD pipelines - because it's command-line based, it can be integrated into your CI/CD pipeline as a performance check before putting something live in the same way you might do with unit and automated tests.
  • Grafana Cloud - for high volume load testing, running k6 from somewhere that's capable of hitting your servers with a high degree of load can be a challenge. Grafana Cloud k6 allows you to do this. You can start off with a free account to try it out and, if you find value in it for some low scale tests, convert to a full subscription.

Load testing is one of those things that’s easy to put off, however, with k6 the barrier to entry is incredibly low. Give it a go; write a quick script for your heaviest endpoint to see how it holds up. It's definitely a lot less stressful finding performance issues on your own machine than it is on go-live day!

About the Author

Tristan Thompson, Head of Development

Tristan is our Head of Development. With two decades of software development experience he loves nothing more than getting to know a client, figuring out what they need and helping them to create clever solutions.