Essay
k6 1.0 and the State of Performance Testing in 2025
Grafana k6 1.0 brings TypeScript, SemVer and stable browser testing. How it compares with Gatling, JMeter and Locust, and how to run performance tests in CI.
15 May 2025 · 11 min · Irfan Ahmad
Performance testing rarely makes headlines. Most months, the interesting news is about AI agents, frameworks or security incidents. This month the most consequential release for many QA teams was a version number. At GrafanaCON 2025 on 6–8 May, Grafana k6 reached 1.0, nearly a decade after the project started and four years after Grafana Labs acquired it.
This essay covers mid-April to mid-May 2025. The anchor is k6 1.0. The deeper theme is the state of performance testing tools in 2025: what’s changed, how the main open-source tools compare, and how to make performance testing a routine part of delivery rather than a pre-launch fire drill.
What k6 1.0 actually changes
A 1.0 release is mostly about promises, and k6’s are useful ones:
- Semantic versioning. Breaking changes now arrive only in major versions, after deprecation warnings.
- Support guarantees. Each major version gets critical fixes for at least two years.
- A stable public Go API for extensions, so the extension ecosystem can build without fear of breakage.
The features matter too:
- Native TypeScript support, with no transpilation step. You can import your app’s existing types into load tests.
- Extension support in Grafana Cloud k6 runs. Supported extensions are resolved automatically when you import them. Support for plain
k6 runwas announced as coming later. - A revamped end-of-test summary, with scenario- and group-level metrics and a
--summary-mode=fulloption. - Graduated modules.
k6/browser,k6/net/grpcandk6/cryptoare now stable.
Here’s what a TypeScript k6 test looks like now. It uses the k6-testing assertion library from the 1.0 announcement:
import http from 'k6/http';
import { expect } from 'https://jslib.k6.io/k6-testing/0.3.0/index.js';
import type { Options } from 'k6/options';
export const options: Options = {
scenarios: {
browse: { executor: 'ramping-vus', stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '1m', target: 0 },
] },
},
thresholds: {
http_req_failed: ['rate<0.01'],
'http_req_duration{endpoint:search}': ['p(95)<400'],
},
};
interface SearchResponse { items: { id: string }[] }
export default function () {
const res = http.get(`${__ENV.BASE_URL}/api/search?q=mug`, { tags: { endpoint: 'search' } });
expect(res.status).toEqual(200);
const body = res.json() as unknown as SearchResponse;
expect(body.items.length).toBeGreaterThan(0);
}
The thresholds block is the most important part for CI. When a threshold fails, k6 exits non-zero and your pipeline goes red. That’s what turns a load test from a report someone reads into a gate.
Why stable browser testing matters
The graduation of k6/browser deserves its own note. It lets one tool measure both protocol-level load (thousands of virtual users hitting APIs) and browser-level experience (Core Web Vitals and rendering for a handful of real browser sessions) in the same test. A common pattern is a hybrid scenario: heavy HTTP load to stress the backend, plus a few browser VUs measuring what a user actually sees under that load.
import { browser } from 'k6/browser';
export const options = {
scenarios: {
api_load: { executor: 'constant-vus', vus: 200, duration: '10m', exec: 'apiFlow' },
ui_probe: {
executor: 'constant-vus', vus: 2, duration: '10m', exec: 'uiFlow',
options: { browser: { type: 'chromium' } },
},
},
thresholds: { browser_web_vital_lcp: ['p(75)<2500'] },
};
export async function uiFlow() {
const page = await browser.newPage();
try {
await page.goto(`${__ENV.BASE_URL}/`);
await page.locator('input[name="q"]').type('mug');
} finally {
await page.close();
}
}
That’s the closest open-source tooling has come to answering the question stakeholders actually ask: “Will the site feel slow on launch day?”
The open-source performance testing landscape
k6 isn’t the only strong option. Here’s how the main open-source performance testing tools compare in 2025:
| Tool | Script language | Execution model | Strengths | Watch out for |
|---|---|---|---|---|
| k6 | JavaScript / TypeScript | Go runtime, goroutine VUs | Developer-friendly, CI thresholds, browser module, Grafana integration | Not Node.js: npm packages need bundling; distributed runs via cloud or operator |
| Gatling | Scala, Java, Kotlin, JS/TS | JVM, async, non-blocking | Very efficient load generation, rich HTML reports, JS/TS SDK since 2024 | JVM tuning; some features in Enterprise edition |
| JMeter | GUI + XML (JMX), Groovy | JVM, thread per user | Huge protocol coverage, plugins, long track record | XML test plans are hard to review; resource-heavy at scale |
| Locust | Python | gevent, distributed workers | Pure Python, easy custom logic, simple distribution | Throughput per worker lower than Go/JVM tools |
| Artillery | YAML + JS | Node.js | Quick YAML scenarios, Playwright integration | Node.js performance limits at very high load |
Gatling’s JavaScript and TypeScript SDK, first released in May 2024, is worth flagging because it narrows k6’s language advantage. JS/TS teams can now choose between the two on architecture and reporting rather than syntax.
Choosing between them
- JS/TS product teams wanting tests in the repo: k6 or Gatling JS. k6 if you’re already on Grafana. Gatling if you want its reports and efficient engine.
- Python teams, or complex custom user behaviour: Locust.
- Protocol breadth (JMS, LDAP, FTP, legacy SOAP) or an existing JMeter estate: JMeter, ideally run headless with Taurus for YAML configuration and CI-friendly output.
- Quick API smoke load from YAML: Artillery.
There’s no wrong choice among these. The bigger risk is not running performance tests regularly at all.
Performance testing types, in practice
Teams often say “load test” for everything. Being precise about the type of test makes results far easier to act on:
| Type | Question it answers | Typical shape |
|---|---|---|
| Smoke | Does the script work, and is baseline latency sane? | 1–5 VUs, 1 minute |
| Average load | Does the system meet SLOs at normal traffic? | Ramp to expected peak, hold 10–30 min |
| Stress | What happens above normal peak? | Ramp beyond peak in steps |
| Spike | Can it absorb a sudden surge? | Jump from low to very high instantly |
| Soak | Does it degrade over time (leaks, connection pools)? | Moderate load for hours |
| Breakpoint | Where exactly does it fail? | Increase until errors or SLO breach |
A healthy cadence for most teams: smoke on every merge, average load nightly or per release, and stress, spike and soak before major launches or infrastructure changes.
Workload modelling: the part that decides whether results mean anything
A load test is only as good as its workload model. The most common reason teams distrust their performance results is that the test doesn’t resemble real traffic. It has every virtual user doing the same thing, with no think time, against one endpoint. A realistic model needs four inputs, all of which can usually be pulled from production analytics or access logs:
- Journey mix. What share of sessions browse, search, add to cart or check out? A typical retail mix might be 60% browse, 25% search, 10% cart and 5% checkout.
- Arrival rate. How many new sessions start per second at normal peak and at the highest peak you’ve seen? Model arrivals, not just concurrent users. k6’s
ramping-arrival-rateandconstant-arrival-rateexecutors exist for exactly this. - Think time and pacing. How long do real users pause between steps? Removing think time turns 1,000 users into the equivalent of tens of thousands.
- Data variety. Real users search for different things, hit different caches and own different-sized carts. A test that requests the same product ID repeatedly measures your cache, not your system.
In k6, an arrival-rate scenario with a weighted journey mix looks like this:
export const options = {
scenarios: {
shoppers: {
executor: 'ramping-arrival-rate',
startRate: 5, timeUnit: '1s', preAllocatedVUs: 100, maxVUs: 1000,
stages: [{ target: 50, duration: '5m' }, { target: 50, duration: '20m' }],
},
},
};
export default function () {
const r = Math.random();
if (r < 0.60) browse();
else if (r < 0.85) search(randomTerm());
else if (r < 0.95) addToCart(randomSku());
else checkout();
sleep(Math.random() * 3 + 1); // 1-4 s think time
}
Revisit the model every few months. Traffic patterns shift with features, marketing and seasons, and a model built a year ago is quietly testing a different product.
Common mistakes that invalidate results
Even with a good tool and a good model, a few classic mistakes can make results meaningless:
- Load generator saturation. If the machine running the test hits 100% CPU, the latencies you measure are the generator’s, not the system’s. Monitor the generator itself.
- Testing through a CDN or WAF unintentionally. You may be load testing your CDN provider, or getting rate-limited by your own firewall. Decide deliberately which layers are in scope.
- Ignoring errors. A fast p95 means nothing if 8% of requests failed quickly. Always read error rates first.
- Averages instead of percentiles. Mean latency hides the tail where users suffer. Use p95 and p99.
- No warm-up. JIT compilation, connection pools and caches make the first minutes unrepresentative. Exclude them or ramp gently.
- Shared environments. A concurrent deploy or another team’s test in the same environment can ruin a run. Book the environment or use an ephemeral one.
A short pre-flight checklist covering these points, run before every significant test, saves days of misdirected tuning.
Performance testing in CI without the pain
The traditional objection to performance testing in CI is that results are noisy and environments aren’t production-like. Both are true, and neither is a reason to skip it. The trick is to use CI performance tests for regression detection, not capacity planning.
# GitHub Actions: k6 smoke + regression thresholds on every PR
- name: Run k6 smoke
uses: grafana/run-k6-action@v1
with:
path: perf/smoke.ts
env:
BASE_URL: ${{ steps.preview.outputs.url }}
Practical rules:
- Compare against a baseline, not an absolute. A 30% increase in p95 compared with
mainis a meaningful signal even in a noisy environment. - Keep CI tests short. Two to five minutes. Longer tests belong in scheduled jobs.
- Tag requests by endpoint so thresholds target the paths that matter.
- Fail on errors strictly and on latency loosely. Error-rate thresholds should be tight. Latency thresholds should allow for environment noise.
- Store results over time. Trend lines catch slow regressions that individual runs miss.
Observability closes the loop
A load test tells you that the system got slow. Observability tells you why. In 2025 the standard approach is to run load tests against an environment instrumented with OpenTelemetry, and to correlate test-generated traffic with traces and metrics. We introduced this idea in our shift-left and shift-right essay.
Two techniques make the correlation easy:
- Tag test traffic. Add a header such as
X-Load-Test-Run: <run-id>, or propagate trace context, so you can filter dashboards and traces to a specific run. - Put test metrics and system metrics on the same dashboard. k6 results can stream to Prometheus or Grafana Cloud, which lets you overlay VU count and latency with CPU, database connections and queue depth.
When p95 latency jumps at 400 VUs and the database connection pool saturates at the same moment, you don’t need a meeting to find the bottleneck.
Front-end performance belongs in the same conversation
Backend load is only half of performance. Core Web Vitals, such as Largest Contentful Paint, Interaction to Next Paint and Cumulative Layout Shift, measure what users feel. Tools like Lighthouse CI, WebPageTest and sitespeed.io let you set performance budgets per page and fail builds that exceed them:
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["warn", { "maxNumericValue": 1600000 }]
}
}
}
}
With k6’s browser module now stable, teams can also measure Web Vitals under load, which lab tools on an idle server never show.
Performance testing LLM features
A new class of performance questions arrived with LLM features. Traditional metrics such as requests per second and response time still apply. They need supplementing with:
- Time to first token (TTFT). What users perceive as responsiveness in streaming interfaces.
- Tokens per second. Generation throughput.
- Cost per request. Token usage translates directly into money.
- Rate-limit behaviour. How the app degrades when the model provider throttles.
Load testing an LLM-backed endpoint with realistic prompt distributions often reveals that the bottleneck is the provider’s rate limit, not your infrastructure. Your fallbacks, queues and user messaging need testing under exactly that condition. We discuss evaluation of LLM features in our August essay.
AI assistance in performance testing
AI helps here too, though less dramatically than in functional testing. The most useful applications in mid-2025 are:
- Script generation from HAR files, OpenAPI specs or recorded browser sessions. Grafana’s k6 Studio desktop app records browser flows as HAR and generates k6 scripts from them.
- Result analysis. Asking an assistant to explain a k6 summary next to system metrics, and to propose likely bottlenecks.
- Workload modelling. Turning production analytics into realistic scenario mixes and think times.
The usual caveat applies. Generated scripts often miss correlation, such as dynamic tokens or session IDs, and realistic pacing. Review them as carefully as any generated test.
Elsewhere in the window
- Playwright 1.52 (17 April) added
toContainClass, per-project worker limits and options to stop a run after a set number of flaky-test failures. It’s small, practical CI hygiene. See our framework comparison for context. - MCP adoption kept growing after OpenAI’s March endorsement, with testing vendors announcing MCP servers. We covered the patterns and risks in April.
Pros and cons: k6 1.0 specifically
Pros
- TypeScript-native, reviewable tests that live alongside application code.
- Thresholds make performance a CI gate, not a report.
- One tool for protocol load and browser experience.
- SemVer and support guarantees reduce upgrade risk.
Cons
- Not Node.js: many npm libraries won’t run without bundling.
- Large distributed tests need Grafana Cloud k6 or a self-managed Kubernetes operator.
- Browser VUs are resource-heavy, so keep them few.
- Extension auto-resolution initially covered cloud execution only.
What this means for your team
Pick one tool and standardise. If you have no performance testing today, k6 1.0 is a strong default for JS/TS teams. Gatling and Locust are equally valid for JVM and Python teams. The best tool is the one your developers will actually maintain.
Start with a smoke test on every PR. One script, two minutes, tight error thresholds and loose latency thresholds compared with baseline. That alone catches a surprising number of regressions.
Schedule a weekly average-load test. Run it against a stable, production-like environment and track trends.
Instrument before you stress. Make sure traces and metrics are available for the environment under test, and tag test traffic.
Add front-end budgets. Put Lighthouse CI budgets on your most important pages.
Plan for LLM-specific performance. If you ship AI features, add TTFT, tokens per second, cost and rate-limit scenarios to your performance plan.
Performance testing is having a quiet renaissance: better tools, typed scripts, CI-native thresholds and observability integration. k6 1.0 is a good moment to move performance from “something we do before Black Friday” to “something every pull request is checked against”.