Browse the Knowledge Hub211 resources
k6 review: what it is, pricing and setup
k6 is an open source load testing tool from Grafana Labs. Tests are JavaScript or TypeScript files run by a single Go binary, which models traffic with scenarios and turns pass or fail criteria into an exit code a pipeline can act on. The tool is free under AGPL-3.0; Grafana Cloud k6 is a paid hosted service for running tests from the cloud and analysing results.
QAble runs this tool on client engagements. The assessment below is from that experience.
- last reviewed
- 13 October 2026
- version covered
- k6 2 (v2.3.0)
- licence
- Open source
At a glance
k6 at a glance
- Vendor
- Grafana Labs
- Category
- Performance testing
- Licence
- Open source (AGPL 3.0, with Grafana Cloud k6)
- Platforms
- Linux, macOS, Windows, Docker
- Languages
- JavaScript, TypeScript
- Integrates with
- Grafana, Prometheus, OpenTelemetry, GitHub Actions, Jenkins, Kubernetes (k6 Operator)
- First released
- 2017
- Version reviewed
- k6 2 (v2.3.0)
Verdict
Our verdict on k6
Strengths
- Tests are JavaScript or TypeScript modules in Git, so they are reviewed, diffed and refactored like application code
- Thresholds turn objectives such as p(95) under 500 ms into exit code 99, which fails a CI job without extra tooling
- Arrival-rate executors hold a request rate even as the system slows, so latency problems are not hidden
- One Go binary covers HTTP/1.1, HTTP/2, WebSockets, gRPC and a Chromium browser module, with no runtime to install
- Importing a k6/x module provisions official and community extensions automatically, with no custom build
Weaknesses
- Not Node.js: npm packages and Node APIs such as fs need a bundler or a k6-specific replacement
- No distributed mode in the CLI; scaling out needs the k6 Operator on Kubernetes or Grafana Cloud k6
- Protocol coverage outside HTTP, gRPC and WebSockets depends on extensions, many maintained by the community
- k6 2 removed commands, flags and an executor, so many published tutorials and scripts fail as written
Best for
Teams that want performance tests in the same repository and review process as the code, run on every merge or every night rather than once before launch. Scripts are small, thresholds turn service level objectives into a failing exit code, and arrival-rate executors let you test a target request rate honestly. It suits HTTP APIs, gRPC and WebSocket services, and teams that already look at Grafana or Prometheus, where k6 results can sit beside the system metrics they explain.
Not for
Estates whose load has to be generated over protocols k6 does not speak natively, such as SAP GUI, Citrix or mainframe terminals, where LoadRunner is the realistic option, or JMS and JDBC work, where JMeter is the shorter path. It is also a poor fit for a team that wants to build tests in a GUI without code, although k6 Studio now records browser sessions into scripts. And anyone expecting npm packages to work as they do in Node.js will be disappointed.
Setup cost
Low. One binary from Homebrew, apt, winget or Docker, and k6 new writes a runnable script. A first useful smoke test with thresholds takes under an hour. The real setup work is deciding the workload model: which journeys, at what arrival rate, with what think time and test data. Agree those numbers with whoever owns the service level objectives first, because a well-written script against the wrong load profile proves very little.
Maintenance burden
Low for the scripts, which are plain modules that diff and refactor like any other code. Correlation is ordinary JavaScript, so a changed response field is a one-line fix. The work that remains is keeping test data and environments realistic, and following the release train: Grafana ships a major roughly once a year and a minor every six weeks, and k6 2 removed several commands and APIs that older scripts and tutorials still use.
Honest limitations
There is no built-in distributed mode in the CLI: beyond one machine you run the k6 Operator on Kubernetes or pay for Grafana Cloud k6. Each virtual user is its own JavaScript runtime, so memory grows with VU count and with any data loaded per VU. Browser testing is Chromium only and costs far more per VU than protocol testing. Thresholds are only as useful as the numbers in them, and k6 cannot tell you what those should be.
How it works
What is k6 and how does it work?
A Go binary with an embedded JavaScript runtime
k6 is a single program written in Go. Your script runs in Sobek, Grafana’s fork of the goja JavaScript engine, while the HTTP client, timers, metrics and outputs are Go code underneath. That is why k6 generates heavy load from modest hardware, and also why it is not Node.js: there is no fs, os or process, and imports use browser-like resolution, so a path must be complete (./lib/auth.js) or a URL, a built-in k6 module or a k6/x extension. npm packages work only after a bundler such as webpack or Rollup has flattened them into one file. TypeScript runs directly since v0.57, transpiled by esbuild, which strips types without checking them.
Each virtual user is a separate runtime
A virtual user (VU) is an independent JavaScript VM that runs your default function in a loop; one pass is an iteration. Code at the top level of the script, the init context, runs once per VU, and it is the only place you can import modules or read files with open(). VU code cannot do either. Because nothing is shared, memory grows with VU count: the k6 docs estimate 1 to 5 MB per VU for simple tests and tens of megabytes when large files or modules are involved. A CSV of test users loaded with open() is copied into every VU unless you wrap it in a SharedArray, which keeps one read-only copy for all of them.
Executors decide what load means
Load shape lives in scenarios, and each scenario names an executor. There are six. constant-vus, ramping-vus, shared-iterations and per-vu-iterations are closed-model executors: a VU starts its next iteration only when the last one finishes, so when the server slows down the test sends fewer requests, which hides the very problem you are looking for. constant-arrival-rate and ramping-arrival-rate are open-model: iterations start at the configured rate whatever the response time, drawing VUs from preAllocatedVUs up to maxVUs. When no VU is free, k6 counts a dropped iteration. The vus, duration and stages options are shortcuts for a single constant-vus or ramping-vus scenario.
Checks record, thresholds decide
A check is a named boolean on a response, such as status is 200. Failed checks never stop the test or change its result; each one only feeds the checks rate metric. Thresholds are what make a run pass or fail. They are expressions over metrics, such as http_req_duration p(95)<500 or http_req_failed rate<0.01, evaluated during and at the end of the run. If any threshold fails, k6 exits with code 99, which any CI system treats as a failed step; abortOnFail stops the run early. Other codes matter too: 107 for a script exception, 108 for exec.test.abort(), and from k6 2, 97 for cloud runs aborted for reasons other than thresholds.
Metrics, tags and outputs
Every request emits samples for built-in metrics such as http_req_duration, http_req_failed and iteration_duration, tagged with the URL, method, status, scenario and any tags you add. At the end k6 prints a summary, compact by default in k6 2 with full and disabled as the alternatives, or whatever handleSummary() returns. During the run, --out streams raw samples to JSON or CSV files, Prometheus remote write, OpenTelemetry, InfluxDB, Grafana Cloud k6 and others, and the web dashboard is built into the binary. Tags carry a cost: every unique URL becomes its own time series, so URLs with IDs in them need a name tag or http.url to group them.
Strengths
What it does well
Performance tests that live beside the code
A k6 test is a module with an options export and a function. It sits in the repository next to the service it exercises, goes through pull request review, and can share helpers with other tests through ordinary imports. Compared with XML test plans, that is the difference between a performance suite the whole team maintains and one only its author can change.
Honest throughput testing with arrival rates
constant-arrival-rate and ramping-arrival-rate let you state the load as iterations per second rather than as a number of looping users. When the system under test slows, k6 adds VUs to hold the rate instead of quietly backing off, and dropped_iterations tells you when it could not. For capacity questions phrased as requests per second, this is the executor family to reach for.
Several protocols from one binary
HTTP/1.1, HTTP/2, WebSockets and gRPC are built in, with HTTP/2 negotiated automatically when the server offers it. TLS options, cookies, multipart uploads and HTML parsing come in core modules. For Kafka, SQL, Redis, MQTT and others there are extensions, so a test that drives an API and then checks a queue can stay in one script and one report.
Browser-level metrics with k6/browser
The browser module drives Chromium through an API modelled on Playwright, with locators, async actions and screenshots, and reports Core Web Vitals such as LCP and INP alongside the HTTP metrics. Its best use is hybrid: a handful of browser VUs watching what users experience while protocol VUs supply the bulk of the load, all in one run with shared thresholds.
Extensions without a custom build
Since automatic extension resolution became the default, importing an official or community module such as k6/x/faker or k6/x/redis is enough: k6 provisions a binary with that extension and runs the script. Private extensions, output extensions and scripts read from stdin still need a custom binary built with xk6, which is a single command with Go or the grafana/xk6 Docker image.
A dashboard and HTML report in the binary
Setting K6_WEB_DASHBOARD=true serves a live dashboard on port 5665 during the run, and K6_WEB_DASHBOARD_EXPORT writes a self-contained HTML report at the end. That covers the gap between the text summary and a full Grafana stack, and the HTML file makes a sensible CI artefact for anyone who needs to see the graphs without a login.
Running part of a script on demand
k6 2.3 added --scenario, which runs only the named scenarios from a multi-scenario script, and --once, which runs a single iteration while keeping the scenario’s function and browser configuration. Together they let one script serve as the smoke test on every commit and the full load test at night, without environment-variable switches inside the script.
Pricing
k6 pricing and licensing
k6 itself is free and open source under AGPL-3.0, with every executor, output and the browser module included. Money enters through Grafana Cloud k6, the hosted service, which is priced by virtual user hours (VUh): peak VUs multiplied by the minutes the test actually ran, divided by 60. There is a Free tier, a Pro tier with a platform fee and pay-as-you-go usage, and Enterprise on an annual commitment. Running k6 on your own machines costs only the infrastructure.
- Licence: AGPL-3.0 for the k6 binary and source. Running it to test your own systems is unaffected; the copyleft terms matter if you modify k6 and offer it to others over a network, so check with legal before embedding it in a product.
- Free tier: Grafana Cloud’s free plan includes 500 virtual user hours a month with community support. A 10-minute test at 100 VUs uses about 17 VUh under the documented formula.
- Pro: a platform fee plus usage. List prices on grafana.com/pricing on 2026-10-13 were a $19 monthly platform fee including 500 VUh, then from $0.15 per VUh, falling with volume.
- Browser VUs are billed at 10 times the protocol rate, every test costs at least 1 VUh (2 if it mixes browser and protocol VUs), and runs executed locally that stream results to the cloud receive a 25% adjustment.
- Enterprise: custom pricing on an annual commitment, with premium support and deployment options including bring your own cloud.
- Self-hosted scale costs engineering time instead: the k6 Operator on Kubernetes, a metrics backend such as Prometheus, and dashboards someone has to maintain.
Checked against the vendor’s own pages on 13 October 2026. Pricing changes; confirm on the official site before buying.
Getting started
k6 tutorial: getting started
Setup, step by step
- Install k6 with Homebrew, the Grafana apt or rpm repository, winget, Docker or a release binary.
- Run k6 version and confirm the binary reports a 2.x release.
- Run k6 new to scaffold script.js, then replace its body with the example below.
- Run k6 run script.js against a test environment, never production without agreement.
- Read the threshold lines at the top of the summary, then run echo $? to see the exit code a pipeline would get.
- Pass the target as a variable with -e BASE_URL=https://staging.example.com so the script works in every environment.
Install
# macOS
brew install k6
# Windows
winget install k6 --source winget
# Debian or Ubuntu: add the dl.k6.io apt repository first, then
sudo apt-get install k6
# Any platform with Docker
docker pull grafana/k6
k6 version
k6 new script.jsA first example
// script.js: run with k6 run -e BASE_URL=https://staging.example.com script.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
// Shortcut for one ramping-vus scenario: up to 20 VUs, hold, then down
stages: [
{ duration: '30s', target: 20 },
{ duration: '1m', target: 20 },
{ duration: '15s', target: 0 },
],
// Any failed threshold makes k6 exit with code 99
thresholds: {
http_req_failed: ['rate<0.01'], // under 1% errors
http_req_duration: ['p(95)<500'], // 95% of requests under 500 ms
checks: ['rate>0.99'], // makes failed checks count
},
};
const BASE_URL = __ENV.BASE_URL || 'https://quickpizza.grafana.com';
export default function () {
const res = http.get(`${BASE_URL}/`);
// A check records pass or fail; on its own it never fails the run
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1); // think time between iterations
}What trips people up first
A run that looks green but should be red. New users add checks, see some fail in the summary, and are surprised that k6 still exits with code 0 and the pipeline passes. Checks only record results; thresholds decide the outcome. Add thresholds for http_req_failed, a latency percentile and, if checks matter, checks with a rate condition, then confirm the exit code changes when you point the script at a failing endpoint. The second hurdle is the default load model: vus and duration loop as fast as responses return, so request rate falls as the server slows. For throughput targets, switch to an arrival-rate executor.
In practice
Using k6 in practice
Organising a load test repository
Keep one entry script per workload under tests/, shared request helpers under lib/, and test data under data/. Imports need full relative paths with the extension, because k6 does not use Node’s resolution. Put each user journey in a function that takes its dependencies as arguments, so one journey can serve smoke, load and soak tests. Load data in the init context through SharedArray so it is held once, not once per VU. Give every request a stable name tag, which keeps metrics readable and grouped when URLs contain IDs, and read environment specifics such as BASE_URL from __ENV rather than hard-coding them.
// lib/orders.js
import http from 'k6/http';
import { check } from 'k6';
export function getOrder(baseUrl, token, id) {
const res = http.get(`${baseUrl}/api/orders/${id}`, {
headers: { Authorization: `Bearer ${token}` },
// One time series for every order ID, not one per URL
tags: { name: 'GET /api/orders/:id' },
});
check(res, { 'order returned': (r) => r.status === 200 });
return res;
}
// tests/orders-load.js
import { SharedArray } from 'k6/data';
import { getOrder } from '../lib/orders.js';
// Runs once; every VU reads the same read-only copy
const users = new SharedArray('users', () => JSON.parse(open('../data/users.json')));
export default function () {
const user = users[Math.floor(Math.random() * users.length)];
getOrder(__ENV.BASE_URL, user.token, user.orderId);
}Modelling real traffic with scenarios
Most production traffic is better described as arrivals per second than as a fixed crowd of looping users, so model the main API load with an arrival-rate executor and use VU-based executors where concurrency is the point, such as users holding sessions open. Scenarios run in parallel within one test, each with its own executor and exported function, and every metric is tagged with its scenario name, so you can set thresholds per scenario. Size preAllocatedVUs from the expected rate multiplied by the iteration duration, set maxVUs as headroom, and add a threshold on dropped_iterations so a test that could not deliver its load does not pass quietly.
import http from 'k6/http';
const BASE_URL = __ENV.BASE_URL;
export const options = {
scenarios: {
api: {
executor: 'ramping-arrival-rate', // open model
exec: 'api',
startRate: 10,
timeUnit: '1s',
preAllocatedVUs: 50,
maxVUs: 200,
stages: [
{ duration: '2m', target: 100 }, // ramp to 100 iterations/s
{ duration: '5m', target: 100 },
],
},
browse: {
executor: 'constant-vus', // closed model
exec: 'browse',
vus: 20,
duration: '7m',
},
},
thresholds: {
'http_req_duration{scenario:api}': ['p(95)<300'],
'http_req_duration{scenario:browse}': ['p(95)<800'],
dropped_iterations: ['count<50'], // the load was actually delivered
},
};
export function api() {
http.get(`${BASE_URL}/api/products`);
}
export function browse() {
http.get(`${BASE_URL}/`);
}Thresholds as a release gate
Thresholds work best when they come from agreed objectives rather than from whatever the last run produced. Start with three: an error rate on http_req_failed, a percentile on http_req_duration, and a checks rate if your checks encode correctness. Add tagged thresholds for the endpoints that matter most, since an overall p(95) can hide one slow call behind many fast ones. For long soak or stress runs, abortOnFail with a delayAbortEval saves runner time once a failure is certain, and the delay stops a cold start from aborting the test early. A failed run marks each broken threshold with a cross and exits with code 99, so the CI log shows what broke without custom parsing.
export const options = {
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500', 'p(99)<1500'],
// Only the checkout call, using the name tag set on the request
'http_req_duration{name:POST /api/checkout}': ['p(95)<800'],
// Stop a long run early, but not during warm-up
'http_req_duration{name:GET /api/search}': [
{ threshold: 'p(99)<2000', abortOnFail: true, delayAbortEval: '1m' },
],
checks: ['rate>0.99'],
},
};Running k6 in GitHub Actions
Grafana publishes two actions, both on v1: grafana/setup-k6-action installs k6 (pin k6-version so results stay comparable, and set browser: true if you need Chrome), and grafana/run-k6-action wraps k6 run with globbed paths, parallel runs and fail-fast. Without cloud credentials it runs k6 locally on the runner, and a threshold failure fails the step. With K6_CLOUD_TOKEN and K6_CLOUD_PROJECT_ID set it streams results to Grafana Cloud k6 by default, or runs in the cloud with cloud-run-locally: false; k6 2 also needs the stack, for example through K6_CLOUD_STACK_ID. Keep pull request runs to short smoke tests, schedule the heavy load tests, and remember that a shared hosted runner is a noisy load generator, so treat its latency numbers as a regression signal rather than a capacity figure.
# .github/workflows/performance.yml
name: performance
on:
pull_request:
schedule:
- cron: '0 2 * * *' # nightly full load test
jobs:
k6:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: grafana/setup-k6-action@v1
with:
k6-version: '2.3.0' # pin for comparable runs
- uses: grafana/run-k6-action@v1
env:
BASE_URL: https://staging.example.com
K6_WEB_DASHBOARD: 'true'
K6_WEB_DASHBOARD_EXPORT: k6-report.html
with:
# Smoke test on pull requests, full load test on the schedule
path: ${{ github.event_name == 'schedule' && './tests/load/checkout.js' || './tests/smoke/checkout.js' }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: k6-report
path: k6-report.html
if-no-files-found: ignoreBrowser and protocol load in one test
Browser VUs are expensive: each one drives a real Chromium instance, and Grafana Cloud bills them at ten times a protocol VU. The pattern the k6 docs recommend is hybrid. Generate the load with protocol-level scenarios and run one or two browser VUs alongside, so you measure what a user sees while the backend is under pressure. A browser scenario must set options.browser.type to chromium. Browser actions are asynchronous, and the built-in check does not await promises, so use the check from the k6-utils jslib for anything that reads from the page. Always close the page in a finally block, or a failed iteration leaves a browser context behind.
import { browser } from 'k6/browser';
import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js';
export const options = {
scenarios: {
ui: {
executor: 'constant-vus',
exec: 'checkFrontend',
vus: 1,
duration: '5m',
options: { browser: { type: 'chromium' } },
},
// ...protocol scenarios generating the real load go here
},
thresholds: {
browser_web_vital_lcp: ['p(75)<2500'],
browser_web_vital_inp: ['p(75)<200'], // INP replaced FID in k6 2
},
};
export async function checkFrontend() {
const page = await browser.newPage();
try {
await page.goto(__ENV.BASE_URL);
// The jslib check awaits the promise; the built-in one would not
await check(page.locator('h1'), {
'heading shown': async (h1) => (await h1.textContent()) !== '',
});
} finally {
await page.close();
}
}Getting results into Grafana and scaling out
For anything beyond a single run, stream metrics to the same place as your system metrics so a latency spike can be read against CPU, database and queue graphs. The Prometheus remote write output, still named experimental-prometheus-rw, sends k6 metrics with a k6_ prefix to Prometheus or any compatible store; trend metrics go out as p(99) by default, so set K6_PROMETHEUS_RW_TREND_STATS to the percentiles your thresholds use, or enable native histograms. When one machine is not enough, the k6 Operator runs a test across Kubernetes pods through a TestRun resource with a parallelism setting. Send every pod’s results to a shared backend so you read one picture of the run rather than several partial ones.
# Stream results to Prometheus remote write during the run
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
K6_PROMETHEUS_RW_TREND_STATS='p(95),p(99),max' \
k6 run -o experimental-prometheus-rw tests/load/checkout.js
# Several outputs at once: raw samples to a file as well
k6 run --out json=results.json -o experimental-prometheus-rw tests/load/checkout.jsPitfalls
Common pitfalls
Relying on checks to fail the build
Why it happens: Checks look like assertions, but a failed check only increments the checks metric. Without a threshold, a run in which half the responses were errors still exits with code 0, and the pipeline goes green.
The fix: Pair checks with thresholds: http_req_failed with a rate limit, a percentile on http_req_duration, and checks rate>0.99 if checks carry correctness. Test the gate once by pointing the script at a failing endpoint.
Measuring throughput with a closed model
Why it happens: With vus and duration, stages or ramping-vus, each VU waits for its response before starting again. As the server slows, the request rate drops, so the test applies less pressure exactly when it should apply more, and latency looks better than users would find it.
The fix: Use constant-arrival-rate or ramping-arrival-rate for request-rate targets, size preAllocatedVUs and maxVUs from the rate and the iteration duration, and set a threshold on dropped_iterations.
Importing npm packages as if k6 were Node.js
Why it happens: k6 runs scripts in its own JavaScript runtime with browser-like module resolution. A bare import such as lodash or uuid is not resolved from node_modules, and packages that need Node APIs such as fs or os fail outright.
The fix: Use k6’s built-in modules and the jslib.k6.io libraries first. For other pure JavaScript packages, bundle the test with webpack or Rollup into a single file. Read files with open() in the init context.
Following tutorials written for k6 1.x or earlier
Why it happens: k6 2 removed k6 login, k6 pause, resume, scale and status, the externally-controlled executor, the positional k6 cloud script.js form, --upload-only, --no-summary, --summary-mode=legacy, options.ext.loadimpact, k6/experimental/redis and the browser_web_vital_fid metric. The REST API server also no longer starts unless --address is set.
The fix: Use k6 cloud login, k6 cloud run script.js, k6 cloud upload, --summary-mode=disabled, options.cloud, k6/x/redis and browser_web_vital_inp. Replace externally-controlled with ramping-vus or an arrival-rate executor, and read the official migration guide before upgrading.
Dynamic URLs exhausting memory
Why it happens: Every distinct URL becomes its own time series. A test that fetches /orders/1 to /orders/50000 creates tens of thousands of series, which inflates memory on the load generator and makes metrics backends slow or expensive.
The fix: Group dynamic URLs with the http.url tagged template or an explicit name tag on each request, and keep custom tags to values with few possible variations.
The load generator becomes the bottleneck
Why it happens: When the k6 machine runs out of CPU, memory or ephemeral ports, response times rise because the client is struggling, not the server. CI runners are small and shared, so this happens sooner than people expect.
The fix: Watch CPU and memory on the generator during the run and keep both clearly below saturation. Set discardResponseBodies where bodies are not read, use SharedArray for data, and move large tests to dedicated machines, the k6 Operator or Grafana Cloud k6.
Synchronous checks in browser tests
Why it happens: Browser locators return promises. The built-in check calls the predicate but does not await it, so a check that reads textContent passes or fails on a pending promise rather than on the page.
The fix: Import check from https://jslib.k6.io/k6-utils/1.5.0/index.js for browser assertions and await it, or await the value before passing it to the built-in check.
Compared
How k6 compares
| Tool | How tests are written | Licence | Concurrency model | Scaling past one machine | Results out of the box |
|---|---|---|---|---|---|
| k6 | JavaScript or TypeScript code | AGPL-3.0, paid Grafana Cloud k6 | Go engine, one JS runtime per VU | k6 Operator on Kubernetes, or Grafana Cloud | Text summary, web dashboard, HTML export |
| Apache JMeter | GUI-built XML plans, Groovy for scripting | Apache 2.0 | One Java thread per virtual user | Built-in remote servers over RMI | HTML dashboard generated from results |
| Gatling | Java, Kotlin, Scala, JavaScript, TypeScript | Apache 2.0, paid Gatling Enterprise | Non-blocking Netty engine, not threads | Load generators in Gatling Enterprise | HTML report for every run |
| Locust | Python code | MIT | gevent; one process uses one core | Built-in master and worker processes | Live web UI, CSV and HTML |
Head-to-head comparisons
k6 vs JMeterFAQ
Frequently asked questions
What is k6?
k6 is an open source load testing tool from Grafana Labs. Tests are JavaScript or TypeScript files run by a single Go binary, which models traffic with scenarios and turns pass or fail criteria into an exit code a pipeline can act on. The tool is free under AGPL-3.0; Grafana Cloud k6 is a paid hosted service for running tests from the cloud and analysing results.
Is k6 free?
Yes. k6 is open source under AGPL-3.0, and every feature of the binary, including scenarios, thresholds, outputs, extensions and the browser module, is free to use on your own machines. Grafana Cloud k6 is the paid part: a hosted service for running tests from Grafana-managed or private load zones and analysing results. Its free tier includes 500 virtual user hours a month, and Pro adds pay-as-you-go usage on top of a platform fee.
Should I choose k6 or JMeter?
Choose k6 when your team writes code, your targets are HTTP, gRPC or WebSocket services, and you want tests reviewed in Git and gating a pipeline through thresholds. Choose JMeter when you need its wider protocol range, such as JMS, JDBC, LDAP or FTP, when testers prefer building plans in a GUI, or when an existing JMeter estate and plugin set already work. For a new API-focused suite, k6 is usually less work to keep running.
What changed in k6 2, and should I upgrade?
k6 v2.0.0 shipped on 11 May 2026, and v2.3.0, released on 21 September 2026, is the current minor. The major removed long-deprecated parts: the k6 login, pause, resume, scale and status commands, the externally-controlled executor, options.ext.loadimpact, k6/experimental/redis and several summary and cloud flags, and cloud commands now need a stack. Upgrade if your scripts avoid those, after reading the migration guide. Grafana still patches the 1.8 line, with v1.8.2 released on 12 October 2026.
Can I use npm packages in a k6 script?
Not directly. k6 does not run on Node.js and does not resolve packages from node_modules, so a bare import fails and anything that relies on Node APIs such as fs or os will not work at all. Pure JavaScript packages can be used after bundling the test with webpack or Rollup into one file. Many common needs are already covered by k6’s built-in modules, the jslib.k6.io libraries and extensions such as k6/x/faker.
Does k6 support TypeScript?
Yes. Since v0.57, k6 run script.ts works without any build step: k6 uses esbuild to strip the types and runs the result. It does not type-check, so run tsc --noEmit separately in CI if you want type errors to fail a build. Install the @types/k6 package in your project to get editor completion for the k6 APIs.
Why did my k6 run exit with code 99?
Exit code 99 means at least one threshold failed. The summary at the end of the run marks each threshold with a tick or a cross and shows the measured value, so you can see which objective was missed. Other non-zero codes have different meanings: 107 is an exception thrown by the script, 108 is a deliberate exec.test.abort(), and 97 is a Grafana Cloud run aborted for a reason other than thresholds.
Can k6 test in a real browser?
Yes, through the built-in k6/browser module, which drives Chromium-based browsers with an API modelled on Playwright and reports Core Web Vitals such as LCP and INP. A browser scenario needs options.browser.type set to chromium, and Chrome or Chromium installed, or the grafana/k6 image with the browser included. Browser VUs use far more resources than protocol VUs, so most teams run a few browser VUs beside protocol load.
How do I run k6 across several machines?
The k6 CLI has no built-in controller and worker mode. On your own infrastructure, the k6 Operator runs a test as a TestRun resource on Kubernetes and splits it across pods with a parallelism setting; send results to a shared backend such as Prometheus to read them together. The alternative is k6 cloud run, which executes the test on Grafana Cloud k6 load zones and is billed by virtual user hours.
Alternatives
k6 alternatives worth weighing
Apache JMeter
Apache-licensed Java load testing tool: test plans are built in a desktop GUI and run from the command line, covering HTTP, JDBC, JMS, LDAP, FTP, TCP and more.
Gatling
Load testing as code on a non-blocking Netty engine, with simulations written in Java, Kotlin, Scala, JavaScript or TypeScript and an HTML report per run.
Locust
Python load testing where user behaviour is defined as ordinary code, with a distributed execution model.
Resources
Where to go next
Keep going
Prepare, or get it done
Picking the wrong tool costs more than picking late.
QAble advises on toolchain decisions and then implements them, with engineers who have run these tools on real delivery. Start with a free QA audit.