---
title: Running K6 Scripts on OctoPerf
url: https://blog.octoperf.com/running-k6-scripts-on-octoperf/
date: '2026-10-23'
authors:
- gpereira
categories:
- Tutorials
tags:
- K6
- Design
- Analysis
- CI/CD
- Maven
description: OctoPerf 18 runs K6 scripts as they are. Import a script and its modules, turn its options into a scenario, feed it project variables, and read its requests, checks and custom metrics in the same reports as JMeter.
---

# Running K6 Scripts on OctoPerf

We just released OctoPerf 18 and its main addition is a new load testing engine: <a href="https://grafana.com/docs/k6/latest/" rel="nofollow">K6</a>. You can now import your K6 scripts and **run them without changing a single line**, next to [JMeter](https://jmeter.apache.org/) and [Playwright](https://playwright.dev/) Virtual Users, in the same scenarios and the same reports.

In this blog post I import a K6 script that browses our public demo shop, validate it, run it and go through the results. The scripts can be downloaded if you want to follow along. I will finish with the things that work differently than with JMeter.


## Table of Contents

- [Why K6 in OctoPerf](#why-k6)
- [Import a K6 script](#import)
- [Edit and validate](#edit-and-validate)
- [Variables and test data](#variables)
- [Beyond HTTP](#protocols)
- [Run and analyze](#run-and-analyze)
- [Mixing engines in one scenario](#mixing-engines)
- [K6 in your CI/CD pipeline](#ci-cd)
- [What works differently](#limits)
- [Conclusion](#conclusion)

## Why K6 in OctoPerf { #why-k6 }

![JavaScript code and the repository it lives in](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/javascript-code-repository.jpg "A K6 test is JavaScript, versioned with the application")

A K6 test is a plain JavaScript file. It usually lives in the same Git repository as the application it tests, goes through the same pull requests and evolves with the code. That's the whole point of Test as Code, and K6 is pretty good at it.

But a K6 script does not handle what happens around the test run:

- spreading the load over several load generators, possibly in several regions,
- splitting the test data so that two load generators never log in with the same account,
- keeping the reports, comparing a test with the previous one and following trends,
- monitoring the servers under load and checking SLAs.

This is what an [OctoPerf](https://octoperf.com/open-source-performance-testing/) project already does for JMeter. **With version 18, K6 becomes our third engine**, along with JMeter for protocol tests and Playwright for real browsers. It is also the first visible result of the work we did in [OctoPerf 17](https://blog.octoperf.com/octoperf-v17-a-new-foundation-for-test-results/) to separate the injection engine from JMeter.

## Import a K6 script { #import }

Our test script browses the catalog of [JPetStore](https://petstore.octoperf.com/). You can **[download it](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-petstore.zip)** to follow along. It is made of two files: `petstore.js`, a module that holds the requests, and `script.js`, the entrypoint:

```js
import { group, sleep } from 'k6';
import { row } from './octoperf.js';
import { viewCatalog, viewCategory, viewProduct } from './petstore.js';

export const options = {
  scenarios: {
    browse: { executor: 'ramping-vus', exec: 'browse', stages: [ /* 1m up to 10, 3m at 10, 1m down */ ] },
    fish: { executor: 'constant-vus', exec: 'fish', vus: 5, duration: '4m' },
    search: { executor: 'constant-arrival-rate', exec: 'browse', rate: 2, timeUnit: '1s', duration: '5m', preAllocatedVUs: 10 },
  },
  thresholds: {
    http_req_duration: ['p(95)<1000'],
    category_duration: ['avg<500'],
  },
};

export function browse() {
  const product = row('products');
  group('Catalog', () => {
    viewCatalog();
    sleep(1);
  });
  group('Product', () => {
    viewCategory(product.category);
    sleep(1);
    viewProduct(product.productId);
    sleep(1);
  });
}
```

It declares three K6 scenarios, two thresholds and two groups, and reads one CSV row on each iteration.

To import it, I used the **K6 Scripts** card of the Virtual User creation and selected both files. The first one is the entrypoint, the others are the modules it imports (OctoPerf matches them by file name).

![The K6 import, with Import Scenario turned on](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-import-wizard.jpg "Two files, and the options K6 resolved on our machine")

The **Import Scenario** option creates an OctoPerf scenario from the `options` of the script. OctoPerf does not execute your script to read them. Instead, you run `k6 inspect --execution-requirements script.js` on your computer and paste its output in the import dialog.

> __Note:__
>
> The `--execution-requirements` flag is important. Without it, K6 does not print any `scenarios` for scripts that use the `vus` or `stages` shortcuts.

Once the import is done, a notification tells what could not be converted:

![The import notification](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-import-notification.png "The arrival-rate scenario is left out, and the notification says so")

In our case **the `search` scenario is missing**. It uses a `constant-arrival-rate` executor, which targets a throughput: two iterations per second, whatever the number of virtual users it takes. An OctoPerf user profile is defined by a number of concurrent users, so there is no equivalent. The import converts what it can and reports the rest, it does not try to approximate it.

The two other K6 scenarios became one user profile each, with their own load and the `exec` function of the original scenario:

![The imported scenario](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-imported-scenario.jpg "browse ramps up to 10 users, fish holds 5 for four minutes")

The [conversion rules](https://api.octoperf.com/doc/design/create-virtual-user/import-k6-scripts/#conversion-rules) documentation lists every executor.

From now on, keep in mind that **the OctoPerf scenario defines the load**. At run time, the `scenarios`, `vus`, `duration` and `stages` of the script are ignored. The sanity check reminds you of it:

![The sanity check warning](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-sanity-check-scenarios.png "The script options are ignored, the scenario decides")

All the other options still apply, thresholds included.

## Edit and validate { #edit-and-validate }

Each file of the Virtual User becomes a [K6 Script action](https://api.octoperf.com/doc/design/edit-virtual-user/action-types/k6-actions/k6-script-action/). The editor checks the JavaScript syntax while you type, and ++ctrl+space++ completes the K6 API as well as **the variables of the project**, written the way the script reads them:

![Auto-completion of a CSV variable](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-editor-variables-completion.png "The products CSV variable of the project, offered as a row call")

You can make small fixes directly in OctoPerf, but I would recommend to keep the reference version of your scripts in Git. The [Download K6 Script files](https://api.octoperf.com/doc/design/edit-virtual-user/action-types/k6-actions/k6-script-action/#run-it-with-k6) entry in the menu of the Virtual User exports them back as a ZIP file, which runs on your computer as we will see below.

To check the script, run a **validation**. It executes a single iteration on a load generator and records every request. The requests are listed in the Debug section, named after their `group` and the `name` tag of the request:

![The exchanges of a validation](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-validation-debug-exchanges.jpg "One exchange per request, under its group, with its timings, headers and body")

Some mistakes are caught before the script even reaches a load generator. For example I made a typo in the CSV variable name: `row('product')` instead of `row('products')`. **The sanity check blocks the validation** and lists the CSV variables declared in the project:

![The sanity check catching an unknown CSV variable](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-sanity-check-unknown-csv.png "The project declares products, not product: the validation stays disabled")

When the script fails at run time, the error points at the faulty line. Here `viewProduct` tries to parse the product page with `res.json()`, like an API test would do, but JPetStore returns HTML:

![A script error in the validation](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-validation-script-error.png "petstore.js turns red, and the stack trace names line 23")

`petstore.js` is displayed in red while `script.js` stays green. The stack trace mentions `petstore.js:23:18`, then the line of `script.js` that calls `viewProduct`. **K6 runs your JavaScript files as is, so the line numbers match the ones of your editor.**

Last thing: the validation settings (provider, location, iterations and K6 options) are saved with the project. The next person who opens the Virtual User validates it the same way.

## Variables and test data { #variables }

The [variables of the project](https://api.octoperf.com/doc/design/edit-virtual-user/action-types/k6-actions/k6-variables/) are available in the script without any configuration:

- constants and secrets are `__ENV` entries, passed to K6 through its environment (never on its command line, nor in the OctoPerf logs),
- CSV and random variables are read with `octoperf.js`, a module that OctoPerf generates next to your scripts.

```js
import { row, csv, random, run } from './octoperf.js';

const product = row('products');      // one row per iteration
const all = csv('products');          // every row of this load generator
const quantity = random('quantity');  // a new value at each call
```

**Each load generator gets its own rows.** The CSV file is split between the load generators of the test, so two of them never read the same line. There is no need to split it yourself with `__VU`: K6 numbers its virtual users per load generator, and the file is already split before the test starts.

Since `octoperf.js` is generated on the load generators, it is not in your repository and `k6 run` would fail on your computer. That's why the ZIP file of **Download K6 Script files** also contains a local copy of the module, its data and the CSV files of the variables. **The downloaded folder runs with `k6 run` as is**, which is pretty handy to debug a script on your machine:

```bash
k6 run --vus 1 --iterations 1 script.js
```

The `--vus` and `--iterations` options replace the scenarios of the script for a single quick run. Constants and secrets are never written to the ZIP file, so pass them with `-e NAME=value` when your script needs them. Also, `row()` reads the whole CSV file on your machine, since there is only one load generator. Our archive is such a download, so you can try it right away.

> __Note:__
>
> To bring your changes back, import the `.js` files of the folder again. OctoPerf leaves out `octoperf.js` and its data file, as it generates its own on the load generators.

## Beyond HTTP { #protocols }

HTTP is not the only protocol available. The `octoperf/k6` Docker image contains the K6 modules plus three xk6 extensions, and **every call they make shows up as a request in the report**, with its response time and status:

| Protocol | Module | A request in the report |
|----------|--------|-------------------------|
| HTTP | `k6/http` | one per request |
| gRPC | `k6/net/grpc` | one per call, unary or streamed |
| WebSocket | `k6/websockets`, `k6/ws` | one per session, its messages counted as sent and received |
| SQL | `k6/x/sql`, Postgres and MySQL drivers | one per query, named after the statement |
| Redis | `k6/x/redis` | one per command |
| Kafka | `k6/x/kafka` | one per produce, flush and consume |

I put together a [second archive](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-protocols.zip) to try them. It contains a `docker-compose.yml` that starts a Postgres database, a Redis cache, a gRPC server and a WebSocket echo server, and a `protocols.js` script that calls all four:

```js
const db = sql.open(driver, `postgres://k6:k6@${HOST}:5432/shop?sslmode=disable`);
const cache = new redis.Client(`redis://${HOST}:6379`);

export default async function () {
  const fish = db.query('SELECT id, name FROM product WHERE category = $1', 'FISH');
  await cache.set(`cart:${__VU}`, fish[0].id, 60);
  grpcClient.invoke('hello.HelloService/SayHello', { greeting: 'JPetStore' });
  await addToCart(fish[0].id); // a WebSocket session
}
```

The load generators must reach the machine that runs these containers, so you need an [on-premise agent](https://api.octoperf.com/doc/on-premise-agent/) on your network for this one. The `TARGET_HOST` constant variable holds the address of the machine. Here is the validation:

![The exchanges of the protocols script](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-validation-protocols.jpg "A SQL query, two Redis commands, a gRPC call and a WebSocket session, each a request")

**Sensitive data is not recorded.** SQL statements keep their placeholders, Redis keys are masked, and neither the connection string nor the Redis credentials appear in the report.

xk6 extensions are compiled into the K6 binary, so you cannot add one through the project: the sanity check rejects a script that imports an extension missing from the image. If you run OctoPerf on-premise, you can [rebuild the image with your own extensions](https://api.octoperf.com/doc/design/edit-virtual-user/action-types/k6-actions/k6-protocols/#adding-your-own-extensions) from the `Dockerfile` it contains.

## Run and analyze { #run-and-analyze }

K6 user profiles have [their own settings](https://api.octoperf.com/doc/runtime/edit-scenario/edit-user-profile/options/k6/), each one matching an option of `k6 run`:

![The Script settings of a K6 user profile](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-user-profile-script.jpg "The entrypoint, the function of the K6 scenario, and how strict the run is")

- Entrypoint and Function are filled in by the import, so the same script can be used by several user profiles,
- Throw on request failure (`--throw`) ends the iteration when, for example, a connection is refused,
- Abort on failed threshold is disabled by default: thresholds are evaluated and reported, but the test runs until the end,
- Environment, HTTP and Network options go from the user agent to blocked hostnames,
- Memory is set to 6 MB per virtual user by default.

For the test run, I increased the load to **300 virtual users for ten minutes**: 250 on `browse` and 50 on `fish`.

![The results tree of the K6 test](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-report-results-tree.png "The entrypoint, its three groups, and the requests of the Product group")

**The test ran for ten minutes without a single error.** The results tree shows the entrypoint, its `group` blocks and their requests, just like the containers of a JMeter Virtual User. When a scenario mixes engines, the K6 and Engine filters isolate one of them. Also note that a failed `check()` counts as a failed assertion, not as a request error.

![The Custom Metrics section](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-report-custom-metrics.png "The custom Trend of the script and its two thresholds, held for the whole test")

The `Trend`, `Counter`, `Gauge` and `Rate` metrics declared in the script are displayed as [custom metrics](https://api.octoperf.com/doc/analysis/edit-bench-report/metrics-selection/#custom-metrics), in the Custom Metrics section of the default report. Our `category_duration` averaged **20 ms** over 58,177 values. Thresholds show up as `threshold:<metric>` metrics, equal to 1 while the threshold holds and to 0 once it fails. Both of ours stayed at 1.

Be careful when reading the response times of groups, **they include the pauses**. For example `Product` takes about 2 seconds: two requests of 20 ms plus two `sleep(1)`. So look at the requests to analyze the server, and at the groups for what the user experiences.

![The K6 Runtime monitor of the load generator](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-report-engine.png "300 virtual users, about 40 % of one core and no garbage collection pressure")

There is no JVM to monitor on a K6 load generator. Instead, the **[K6 Runtime](https://api.octoperf.com/doc/analysis/edit-bench-report/agent-monitoring/#k6-monitoring)** monitor follows the K6 process: CPU, memory, Go heap, garbage collection and file descriptors. It raises an alarm when the garbage collector uses more than 5 % of the CPU or when the memory gets close to its limit, which usually means that the virtual users need more memory. We were far from it: with 300 virtual users on a single load generator, K6 ran around 550 goroutines and used about 40 % of one core and 5 % of the memory reserved. Garbage collection time was negligible.

## Mixing engines in one scenario { #mixing-engines }

A K6 Virtual User is a Virtual User like any other, so **a scenario can run it along with JMeter or Playwright ones**. For example, K6 can generate the protocol load at a low cost while a few Playwright users measure the [Core Web Vitals](https://blog.octoperf.com/octoperf-18-test-as-code-with-k6-and-core-web-vitals-for-playwright/) of the pages as the load rises.

Reports identify Virtual Users by their name. That's why the sanity check now warns you when two of them have the same name, whatever their engine.

## K6 in your CI/CD pipeline { #ci-cd }

Version `2.7.0` of the [Maven plugin](https://api.octoperf.com/doc/runtime/edit-scenario/integrations-automation/ci-cd/maven/#k6-scripts) imports the script stored in your repository at every build, using the new `import-k6` goal:

```xml
<plugin>
  <groupId>com.octoperf</groupId>
  <artifactId>octoperf-maven-plugin</artifactId>
  <version>2.7.0</version>
  <configuration>
    <apiKey>${api}</apiKey>
    <workspaceName>Demo</workspaceName>
    <projectName>JPetStore K6 CI</projectName>
    <providerName>OctoPerf</providerName>
    <location>eu-west-1</location>
    <k6Entrypoint>${project.basedir}/script.js</k6Entrypoint>
    <k6Modules>
      <k6Module>${project.basedir}/petstore.js</k6Module>
    </k6Modules>
    <scenarioName>Scenario</scenarioName>
    <timeoutMinutes>30</timeoutMinutes>
  </configuration>
</plugin>
```

```bash
mvn octoperf:wipe-project octoperf:import-k6 octoperf:execute-scenario -Dapi=YOUR_API_KEY
```

The plugin runs `k6 inspect` for you on the build machine. **The `execute-scenario` goal fails the build when the test ends in error or exceeds its timeout.** Also note that `wipe-project` empties the project first, so use a dedicated project for your pipeline.

Actually, our demo script would fail this build! The `search` scenario cannot be converted, and `failOnUnconverted` is `true` by default. In a CI pipeline I prefer a red build to a test that quietly runs with less load than expected. So either remove the scenario from the script, or set the flag to `false` once you have read the warning.

Jenkins, GitLab CI or GitHub Actions only need to run this Maven command. Check our [continuous testing](https://octoperf.com/use-cases/continuous-testing-solutions/) page for more information.

## What works differently { #limits }

Most of OctoPerf features apply to K6 Virtual Users. The [K6 limits](https://api.octoperf.com/doc/design/edit-virtual-user/action-types/k6-actions/k6-limits/) documentation page lists the exceptions. Here are the ones you should know before starting:

- **there is no open model**: `constant-arrival-rate` and `ramping-arrival-rate` executors are never converted, because a scenario runs a fixed number of virtual users and not a throughput (this is the main limitation),
- the scenario defines the load, the `scenarios`, `vus`, `duration` and `stages` of the script are ignored,
- thresholds do not stop the test unless Abort on failed threshold is enabled in the user profile, and even then only the load generator where the threshold failed stops,
- `k6/browser` is not supported, use a [Playwright Virtual User](https://blog.octoperf.com/playwright-vs-jmeter/) instead,
- npm packages are not supported, only relative and `https://` imports, so you have to bundle your dependencies first,
- there is no APM integration, the APM settings of a scenario only apply to JMeter,
- the started and finished users in the report are in fact the iterations of K6,
- each load generator is limited to 100 custom metric names, so never build a metric name from an id or a URL.

## Conclusion { #conclusion }

To sum up, it took two files, one `k6 inspect` command and one import to run our K6 script in OctoPerf. **The script was not modified**, and OctoPerf brought what K6 lacks on its own: load generators, split test data, reports and monitoring. Both [archives](https://blog.octoperf.com/img/blog/k6-load-testing-octoperf/k6-petstore.zip) and JPetStore are public, so you can replay every step of this blog post.

If you use Gatling rather than K6, have a look at our [Gatling migration walkthrough](https://blog.octoperf.com/migrating-gatling-simulations-to-octoperf-with-an-ai-agent/): it converts a simulation into a K6 Virtual User with an AI agent. And to try it with your own scripts, [start a free trial](https://octoperf.com/pricing/).
