CodeWorlds
Back to collections
Guide20 min readCodeWorlds Team

k6, load tests written in JavaScript

k6 drives traffic from a Go engine while you write the scenario in JavaScript. Version 2.2.0, the AGPL-3.0 licence, the browser module and Cloud pricing.

k6, load tests written in JavaScript

k6 is a program written in Go that generates traffic according to a scenario written in JavaScript and collects metrics from it. The current release on the second branch is 2.2.0 from 10 August 2026, the grafana/k6 repository holds roughly 31.3 thousand stars, and the licence is AGPL-3.0 in its verbatim form, with no added exception for linking against other code.

What k6 measures and what it does not

The most common misunderstanding around k6 comes from the fact that a scenario looks like an end-to-end test while doing something entirely different. An end-to-end test checks whether a feature works: it clicks a button, waits for an element, compares text. A load test checks how the system behaves when several hundred users perform the same operation at once. The first answers the question "does this work at all", the second answers "at what intensity does it stop working".

k6 belongs to the second category and the consequences show up in its architecture. By default no browser is started. k6 sends HTTP requests straight from Go, so there is no DOM, no CSS, no client-side script execution and no rendering time measurement. What you measure is backend response time: connection queueing, TLS negotiation, time to first byte, content download time. If your application slows down because of a heavy JavaScript bundle in the browser, k6 running in protocol mode will not see it.

JavaScript here plays the role of a scenario description language rather than an execution environment. The script runs in Sobek, an ECMAScript interpreter written in Go, not in Node. Every virtual user gets a separate instance of that interpreter started inside a goroutine, which lets one machine hold thousands of parallel sessions without the cost of thousands of processes. The price for that is concrete: you have no access to Node built-in modules, you cannot import an arbitrary npm package while the test runs, and anything you need from the npm ecosystem has to be bundled into a single file beforehand.

Since version 0.57 files with the .ts extension are handled by default. k6 pushes them through esbuild, which strips type annotations. That is stripping, not checking: a type error in the scenario will not stop the run. If verification matters to you, tsc --noEmit has to stay in the pipeline separately, exactly as in any other TypeScript project. Compatibility mode is set with the --compatibility-mode flag or the K6_COMPATIBILITY_MODE variable, and the default value extended differs from base only by aliasing the global variable onto globalThis for code carried over from Node.

A practical rule for picking a tool follows from that split. If the question is "does this feature still work after the last change", the answer lies outside k6. If the question is "how many concurrent sessions will this endpoint serve before the ninety-fifth percentile crosses half a second", k6 is exactly what you want. Both kinds of test often run in the same pipeline, but they measure different things, and confusing them leads to conclusions you cannot defend.

Version, licence and project health

Release numbering needs a moment of attention, because two branches are maintained in parallel. Version 2.0.0 shipped on 11 May 2026, 2.1.0 at the end of June and 2.2.0 on the tenth of August. Two days after 2.2.0, on the twelfth of August, version 1.8.1 appeared, a patch on the older line. If you check the newest release by date, you get 1.8.1 and draw the wrong conclusion that the project moved back a major version.

The move to version two was debt clearing. The Go module path changed from go.k6.io/k6 to go.k6.io/k6/v2, which forces an import change in every extension. The commands k6 login, k6 pause, k6 resume, k6 scale and k6 status are gone, together with the externally-controlled executor they relied on, and with no replacement. The flags --no-summary and --upload-only were removed, support for options.ext.loadimpact was replaced by options.cloud, the k6/experimental/redis module dropped out, and the positional form k6 cloud script.js gave way to k6 cloud run script.js. The HTTP control server no longer starts by default and has to be enabled with the --address flag. The web-vitals library was raised to 5.1.0, which removed the deprecated FID metric.

The licence is the point to read carefully before k6 enters a company repository. Three sources say the same thing and none of them carries an exception. The LICENSE.md file on the main branch is six hundred and sixty lines of verbatim GNU Affero General Public License version 3 text, with no appended section granting additional permission to link. The README.md file states outright that k6 is distributed under the AGPL-3.0 licence. The GitHub programming interface reports the same identifier for the repository. That is a rare case of full agreement, except that the agreement concerns the licence with the strongest contagious effect among the popular open-source licences.

In practice this means three things. Running k6 as a tool in a continuous integration pipeline creates no obligations, because AGPL binds distribution and network availability rather than mere use of the program. Building your own k6 binary with extensions and handing it outside the organisation is already distribution of a derivative work, so the extension code has to be available under AGPL terms. Exposing a service where somebody else's user drives your modified k6 over a network triggers section thirteen and the duty to provide that user with sources.

Two things around k6 carry licences different from the core and are easily confused with the licence of the tool itself. The @types/k6 package comes from DefinitelyTyped, sits at version 2.2.0 from 17 August 2026 and is licensed MIT. These are type declarations only, so adding them to devDependencies pulls nothing copyleft into the project. The tool for building custom binaries, grafana/xk6, is under Apache 2.0, and that is a frequent source of the wrong conclusion that extensions can be kept under a permissive licence. Apache 2.0 covers the builder itself, not the product of its work. The extensions themselves carry mixed licences anyway: grafana/xk6-dashboard and grafana/xk6-faker are AGPL-3.0, while grafana/xk6-sql is Apache 2.0.

There is also a distribution discrepancy that any audit will find. The k6 package in the npm registry is a stub. It carries the number 0.0.0, was published on 13 June 2017, describes itself as "Dummy package for autocompleting k6 scripts", declares AGPL-3.0 in the license field, and after unpacking the archive contains exactly one file, package.json. There is neither code nor licence text inside. The official binary archive looks similar: k6-v2.2.0-linux-amd64.tar.gz weighs about thirty megabytes and unpacks into a directory holding a single executable. No licence file is inside, even though AGPL requires its text to accompany conveyed copies. If you redistribute that archive internally in your company, add LICENSE.md from the repository yourself.

Installation and the first scenario

k6 installs as a single binary, through a system package manager or a container image. Installing through npm will not work, for the reason described above.

Code
Bash
# macOS
brew install k6

# Debian and Ubuntu, after adding the Grafana repository
sudo apt-get install k6

# container, no system installation
docker run --rm -i grafana/k6 run - < script.js

# scaffold for a protocol scenario
k6 new script.js

# scaffold for a browser scenario
k6 new --template browser browser-script.js

# run with options overridden from the command line
k6 run --vus 50 --duration 30s script.js

# single pass, skipping the setup and teardown stages
k6 run --iterations 1 --no-setup --no-teardown script.js

The simplest scenario fits in a dozen or so lines and shows all three elements every later test rests on: the request, the response check and the threshold deciding the outcome.

Code
JavaScript
import http from 'k6/http'
import { check, sleep } from 'k6'

export const options = {
  vus: 50,
  duration: '2m',
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1200'],
    http_req_failed: ['rate<0.01'],
    checks: ['rate>0.99']
  }
}

export default function () {
  const res = http.get('https://test.k6.io/contacts.php')

  check(res, {
    'status 200': (r) => r.status === 200,
    'body not empty': (r) => r.body.length > 0
  })

  sleep(1)
}

The check function does not abort the iteration when a condition fails. It only increments the checks metric counter and moves on, because a load test is meant to finish the measurement rather than fall over on the first error. What decides the outcome of the whole run is the thresholds section: a breached threshold yields a non-zero exit code, meaning a red result in the pipeline. Without thresholds k6 run returns zero even when half the requests came back as server errors.

Scenarios, executors and thresholds

The vus plus duration pair is enough for a first measurement and enough for nothing beyond that. Real scenarios are defined in the scenarios section, where every entry picks an executor, meaning the way load is spread over time. There are six executors: shared-iterations, per-vu-iterations, constant-vus, ramping-vus, constant-arrival-rate and ramping-arrival-rate.

The split runs along one boundary you have to understand for the results to make sense. Executors based on virtual users hold a constant number of parallel sessions, so when the server slows down the request rate drops by itself and the test stops measuring what it was meant to measure. Executors based on arrival rate hold a constant number of iterations per second regardless of response time, allocating a pool of virtual users for the job. To check whether a service survives three hundred orders per minute, only the second variant is correct.

Code
JavaScript
import http from 'k6/http'
import { Trend, Counter } from 'k6/metrics'
import exec from 'k6/execution'

const checkoutDuration = new Trend('checkout_duration', true)
const checkoutErrors = new Counter('checkout_errors')

export const options = {
  scenarios: {
    browsing: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 100 },
        { duration: '5m', target: 100 },
        { duration: '2m', target: 0 }
      ],
      gracefulRampDown: '30s',
      tags: { scenario: 'browsing' }
    },
    checkout: {
      executor: 'constant-arrival-rate',
      rate: 300,
      timeUnit: '1m',
      duration: '9m',
      preAllocatedVUs: 60,
      maxVUs: 200,
      startTime: '30s',
      exec: 'checkout'
    }
  },
  thresholds: {
    'http_req_duration{scenario:browsing}': ['p(95)<400'],
    checkout_duration: ['p(95)<900'],
    checkout_errors: ['count<20'],
    dropped_iterations: ['count<1']
  }
}

export default function () {
  http.get('https://test.k6.io/')
}

export function checkout() {
  const res = http.post('https://test.k6.io/login.php', {
    login: `user_${exec.vu.idInTest}`,
    password: 'secret'
  })

  checkoutDuration.add(res.timings.duration)
  if (res.status >= 400) {
    checkoutErrors.add(1)
  }
}

A few details in that file deserve spelling out. The exec field points at an exported function, so a single file serves several independent traffic profiles at once. The preAllocatedVUs and maxVUs fields are mandatory for rate-based executors: k6 reserves the pool up front, because creating a new virtual user mid-test costs time. The dropped_iterations metric counts iterations that could not start on schedule for lack of free users, and a threshold on it is the cheapest way to detect that the traffic generator, rather than the system under test, was the bottleneck. The brace syntax inside a threshold name narrows it to a specific tag, so one scenario does not spoil the result of another.

Custom metrics and result outputs

k6 collects a couple of dozen built-in metrics. The ones used most often are http_req_duration, the full request time, http_req_waiting, the time to first byte alone, http_req_failed as a failure rate, http_reqs as a counter, iterations and iteration_duration for scenario passes, vus and vus_max for the crew size, data_received and data_sent for volume, plus checks, group_duration and dropped_iterations. On top of that come http_req_blocked, http_req_connecting and http_req_tls_handshaking, which break down the connection setup overhead, and grpc_req_duration for gRPC.

Custom metrics are added through four types from the k6/metrics module: Counter for counting, Gauge for the last value, Rate for a success proportion and Trend for a distribution with percentiles. The second argument to the Trend constructor sets the value interpretation to time, which only changes the formatting in the summary.

Code
Bash
# event stream to a file, one sample per line
k6 run --out json=raw.json script.js

# write to a time-series database through Prometheus remote write
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
  k6 run --out experimental-prometheus-remote-write script.js

# export through OpenTelemetry
k6 run --out opentelemetry script.js

# built-in browser dashboard plus an HTML report
K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=report.html k6 run script.js

# narrow down the statistics in the standard output summary
k6 run --summary-trend-stats="min,med,p(95),p(99),max" script.js

The list of built-in outputs covers json, csv, cloud, influxdb, opentelemetry and experimental-prometheus-remote-write, plus the web dashboard driven by the K6_WEB_DASHBOARD, K6_WEB_DASHBOARD_EXPORT, K6_WEB_DASHBOARD_HOST, K6_WEB_DASHBOARD_PORT, K6_WEB_DASHBOARD_OPEN and K6_WEB_DASHBOARD_PERIOD variables. Two outputs people still remember no longer exist: kafka dropped out in version 0.34.0 and statsd in 0.55.0, and instead of working k6 returns a message pointing at an external extension. Whatever is missing from that list you pick up through xk6 by building your own binary, and then you are back to the licence considerations from the earlier section. To correlate a latency spike with a specific application exception, it is easier to line the k6 result up against events from Sentry than to hunt for the cause in the numbers alone.

The browser module and the end-to-end boundary

The exception to the "k6 does not start a browser" rule is the k6/browser module, built into the core and driving a real Chromium-based browser over the Chrome DevTools Protocol. The interface is deliberately close to what Playwright offers: browser.newPage, page.goto, page.locator, page.screenshot. Since version 0.52.0 the whole API is asynchronous, so nearly every method returns a promise.

Code
JavaScript
import { browser } from 'k6/browser'
import { check } from 'k6'

export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      vus: 5,
      iterations: 20,
      options: {
        browser: { type: 'chromium' }
      }
    }
  },
  thresholds: {
    browser_web_vital_lcp: ['p(95)<2500'],
    browser_web_vital_cls: ['p(95)<0.1'],
    checks: ['rate==1.0']
  }
}

export default async function () {
  const page = await browser.newPage()

  try {
    await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' })
    const heading = page.locator('h1')
    check(await heading.textContent(), {
      'heading visible': (t) => t !== null && t.length > 0
    })
  } finally {
    await page.close()
  }
}

The module adds metrics of its own: browser_data_received, browser_data_sent, browser_http_req_duration, browser_http_req_failed and a set of Core Web Vitals in the form of browser_web_vital_lcp, browser_web_vital_fcp, browser_web_vital_cls, browser_web_vital_inp and browser_web_vital_ttfb. The FID metric disappeared along with the web-vitals library upgrade in version 2.0.0, so a threshold on it simply will not fire. Browser behaviour is driven by the K6_BROWSER_HEADLESS, K6_BROWSER_ARGS, K6_BROWSER_EXECUTABLE_PATH, K6_BROWSER_TIMEOUT, K6_BROWSER_IGNORE_DEFAULT_ARGS, K6_BROWSER_DEBUG and K6_BROWSER_TRACES_METADATA variables. Version 2.2.0 added chromium.connectOverCDP(), which attaches the test to an already-running browser instance at a WebSocket address supplied while the script runs.

The scale of this mode is entirely different from protocol mode. A single Chromium instance eats hundreds of megabytes of memory and a noticeable fraction of a core, so five browser users can be heavier than five hundred protocol ones. A sensible arrangement keeps the load on the protocol layer while running a handful of browser users alongside, to measure how Web Vitals react to that load. For ordinary functional regression the browser module is not the right tool, and there Cypress or Playwright itself will serve better, if only because they have more mature error reporting and run recording.

Grafana Cloud k6 and pricing

Grafana Cloud k6 is a service where the same script runs from the provider's load zones and the results land in a dashboard alongside the rest of your telemetry. The commands are k6 cloud run script.js and k6 cloud run --local-execution script.js, with the second variant generating traffic from your own machine and sending only the results to the cloud. Since version 2.2.0 it sends logs too, unless you add --no-cloud-logs. Version 2.0.0 tightened two things: every cloud command requires an explicitly named stack, and aborting a test for a reason other than a breached threshold returns exit code 97 instead of the former zero.

Billing runs in virtual user hours, abbreviated as VUh. The free plan costs zero and gives five hundred VUh per month with community support. The Pro plan in its self-serve variant carries a platform fee of nineteen dollars per month, which includes those same five hundred VUh, and everything above that is billed by usage. The rate falls with volume: one hundred and fifty thousandths of a dollar per VUh in the range from one to three and a half thousand, one hundred and thirty-five thousandths in the range from three and a half to seven and a half thousand, and one hundred and twenty thousandths above seven and a half thousand. The Enterprise plan lists no rate, and the only number on the page is a minimum commitment of twenty-five thousand dollars per year.

One thing follows from that arrangement that is easy to miss: the five hundred VUh allowance is identical on the free and the paid plan, so nineteen dollars a month buys support on an eight-hours-by-five-days basis and the right to exceed the allowance, rather than a larger allocation. The VUh unit itself can mislead too, because it is neither test duration nor request count. A test with five hundred virtual users running for twelve minutes consumes roughly one hundred VUh, so a dozen or so such passes exhaust the free pool within a month.

Vendor lock-in here is limited, and that is a genuine advantage of the arrangement. The script stays an ordinary JavaScript file that runs locally without any account, and the only things tying it to the cloud are the options.cloud section and the choice of load zones. Moving out amounts to pointing the output somewhere else and restoring your own traffic-generating machines, for instance containers on Railway or operator nodes inside a cluster.

k6 against the alternatives

Featurek6Apache JMeterGatlingLocustPlaywright
Scenario languageJavaScript or TypeScriptXML with a graphical editorScala, Java or KotlinPythonJavaScript or TypeScript
Execution engineGoJVMJVMPythonNode with a browser
Starts a browseronly through the browser modulenononoalways, that is its job
Load modelvirtual users and arrival ratethreadsvirtual usersconcurrent tasksnone, one pass at a time
LicenceAGPL-3.0Apache 2.0Apache 2.0MITApache 2.0

The choice resolves quickly. If the team writes in JavaScript and wants performance tests in the same repository as the application, k6 is the natural candidate, provided AGPL is acceptable. If a copyleft licence is ruled out by company policy, Gatling and JMeter under Apache 2.0 remain. If the backend is in Python anyway, Locust saves one language in the project. And if what you actually need is to check whether a form still works after the last change, that is not a job for a load testing tool but for Playwright and a set of unit tests in Vitest.

Common mistakes

The first is a test without thresholds. Without a thresholds section the process always ends with exit code zero, so such a step in a continuous integration pipeline shows green regardless of results. The sensible minimum is a threshold on http_req_failed and one percentile on http_req_duration.

The second is measuring your own generator instead of the system under test. When the machine running k6 saturates a core or its bandwidth, the growing latencies come from it rather than from the server. The symptom is a non-zero dropped_iterations metric and a flat throughput chart despite a rising user count. The threshold dropped_iterations: ['count<1'] catches this immediately.

The third is sticking with constant-vus when the question concerns requests per second. A constant user count with slower responses lowers the rate by itself, so the test quietly eases off exactly when it should be pushing. That is where constant-arrival-rate or ramping-arrival-rate belong.

The fourth is passing an asynchronous function to check or group. The built-in check rejects such a call with a message pointing at the k6-utils library, available at https://jslib.k6.io/k6-utils/1.6.0/index.js, while group simply raises an error. In browser tests, where almost everything is a promise, this is the first thing you trip over.

The fifth is treating TypeScript support as type checking. esbuild strips annotations without verifying them, so a type mismatch in a scenario passes through k6 run without a word.

The sixth is trying to install k6 with npm install k6. That package is a 2017 stub without a single line of code, and it still finds its way into package.json files across many projects and into dependency audit reports.

The seventh is comparing k6 results with what a user perceives. In protocol mode there is no rendering, so an http_req_duration around one hundred milliseconds says nothing about whether the page feels fast. The browser_web_vital_* metrics or measurements from real traffic serve that purpose.

The eighth is leaving the data layer out of the analysis. If a load test shows a tail of the distribution near one second, the cause usually sits in an unindexed query or an exhausted connection pool to PostgreSQL rather than in the application itself. Without database metrics gathered over the same time window, the k6 result only tells you that things are slow.

FAQ

Will k6 replace end-to-end tests?

No. In protocol mode k6 sends HTTP requests without a browser, so it will check neither element visibility nor client-side script behaviour. The k6/browser module drives a real Chromium and covers part of that ground, but its purpose is measuring Core Web Vitals under load rather than functional regression.

Does the AGPL-3.0 licence block using k6 in a closed-source company?

Running k6 in a pipeline creates no obligations, because AGPL binds distribution and network availability. Obligations appear once you build your own binary with extensions and pass it outside the organisation, or expose a modified version as a network service. The @types/k6 declarations are a separate package under MIT.

How many virtual users will one machine hold?

In protocol mode every user is a goroutine with its own interpreter instance, so the order of magnitude is thousands on an average server, limited mainly by memory and socket count. In browser mode the number drops to single digits or a few dozen, because every user is a separate Chromium instance.

Can I import an npm package inside a scenario?

Not directly. The script runs in the Sobek interpreter written in Go rather than in Node, so Node built-in modules and packages from node_modules are unavailable at run time. The dependency has to be bundled into a single file beforehand, or replaced with a ready-made library from jslib.k6.io.

How does the free Grafana Cloud k6 plan differ from Pro?

Both give five hundred virtual user hours per month. Pro adds a platform fee of nineteen dollars per month, support on an eight-hours-by-five-days basis, and billing for usage above the allowance at a rate that falls with volume. The Enterprise plan requires a commitment starting at twenty-five thousand dollars per year.

What changed in version 2.0.0 and does migrating hurt?

The Go module path changed to go.k6.io/k6/v2, which concerns extension authors. From scripts, options.ext.loadimpact, the k6/experimental/redis module and the externally-controlled executor are gone. From the command line, k6 login, k6 pause, k6 resume, k6 scale, k6 status, --no-summary and --upload-only dropped out. A typical protocol scenario runs unchanged.

Documentation lives on the Grafana site, the cloud service pricing on the plans page, and the source code in the GitHub repository.

Read next

We use cookies to enhance your experience on the site