skip to content

Specialised Clients

What k6 reaches for once plain HTTP is not enough: a gRPC client, two WebSocket modules and a real Chromium, each a separate import with its own lifecycle and its own metrics.

on this pageshow

explore

questions

10

In k6 v2, what happens to a script that imports k6/experimental/browser, and what replaces it?

level: juniorimportance: must knowfreq 62%

answer

  1. the experimental path is gone
  2. graduated, not aliased
  3. importing it throws, never warns
  4. k6/browser, and add await

basics

~10 s

The import throws straight away with a migration message: in k6 v2 the browser module has graduated and k6/experimental/browser was removed. Import browser from k6/browser instead, and await its calls.

solid answer

~40 s

In k6 v2 the browser module is stable at `k6/browser`, and the old `k6/experimental/browser` path was removed. k6 still registers that old path internally, but only so it can throw a migration error telling you to switch imports rather than fail with a bare module-not-found. The replacement is `import { browser } from 'k6/browser';`. Changing the string is usually not enough on its own: the same graduation turned most browser calls into promise-returning ones, so `browser.newPage()`, `page.goto()` and `page.close()` all need `await`, and the exported entry function must be declared `async`. Nothing about the scenario configuration changes — a browser scenario is still switched on by `options.browser.type` set to `'chromium'`.

code

javascript · 21 lines
javascript
import { browser } from 'k6/browser';

export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      options: {
        browser: { type: 'chromium' },
      },
    },
  },
};

export default async function () {
  const page = await browser.newPage();
  try {
    await page.goto('https://quickpizza.grafana.com/');
  } finally {
    await page.close();
  }
}

go deeper

for a junior

Remember the one-line fact: in k6 v2 the browser module lives at k6/browser, and the old k6/experimental/browser import throws. Recognising the migration message in a stack trace is most of the value here.

for a middle

Be able to explain that the removal is a throwing stub rather than a missing module, and that the graduation also made the API promise-based, so the entry function must be async and every browser call awaited.

for a senior

Show how you would sweep an inherited suite: grep for k6/experimental/, fix imports, then hunt the silent half-migration where an unawaited newPage yields a promise and the next line fails on a missing method.

for a principal

Frame it as the cost of depending on an experimental namespace. Decide what your team standardises on — pinned k6 versions in CI, a lint rule banning k6/experimental/ imports, and who owns the sweep when a module graduates.

## What the old import does now In k6 v2, `k6/experimental/browser` is not a module you can load. k6 still keeps that path in its internal module table, and reading the source is what makes the behaviour obvious: the entry is not a module implementation at all, it is a **removed-module stub** whose only job is to throw the moment a VU instantiates it. The error text names the replacement and links the migration guide, so you get a sentence you can act on instead of a bare "module not found". That is a **script exception**, not a warning. It happens while the VU is being built, before any iteration runs, so the test produces no metrics and no partial results — it simply stops. There is no flag that re-enables the old path and no shim that forwards it to the new one. ## Why it throws instead of resolving quietly The browser module was experimental for a long time, and a large amount of published k6 material still shows the old path. A silent alias would have let those scripts keep running while the documentation, the metrics and the async API all moved on, so k6 chose the loud option: the removal is announced at the one moment a reader is guaranteed to see it. The same treatment was applied to the other experimental paths that graduated, each with its own message naming its own replacement. ## The replacement ```javascript import { browser } from 'k6/browser'; ``` `k6/browser` is a **stable** module in k6 v2 — no experimental prefix, no feature flag, no extension build. Three properties can be named in that import: - **`browser`** — the entry point for the browser k6 manages for you, exposing `newPage()`, `newContext()`, `context()`, `closeContext()`, `isConnected()` and `version()`. - **`chromium`** — a browser type whose `connectOverCDP()` attaches to a Chromium that is already running instead of launching one. - **`devices`** — the table of predefined device-emulation settings. ## Renaming the import is only half the migration The graduation that moved the module also converted most of its API to **asynchronous** calls: `browser.newPage()`, `page.goto()`, `page.close()` and nearly every page interaction return a promise now. Two consequences follow mechanically, and both are compile-or-hang failures rather than subtle ones: 1. The exported entry function has to be marked `async`, because `await` is illegal inside a plain `export default function () { … }`. 2. Values have to be awaited **before** you assert on them. The built-in `check()` exported by the `k6` module rejects an async predicate outright rather than quietly awaiting it, so a browser assertion either reads the value first with `await` and passes a plain boolean, or uses the async-aware `check` from the k6-utils jslib. | In a pre-graduation script | In k6 v2 | |---|---| | `import { browser } from 'k6/experimental/browser'` | `import { browser } from 'k6/browser'` | | `export default function () {` | `export default async function () {` | | `const page = browser.newPage()` | `const page = await browser.newPage()` | | `page.goto(url)` | `await page.goto(url)` | | `page.close()` | `await page.close()` | A script that changes the import but forgets the `await` does not throw — it gets a pending promise where it expected a `Page`, and then fails on the next line with a type error about a method that does not exist on a promise. That is the single most common symptom of a half-finished migration. ## What the rename does not touch Everything outside the import statement stays put: - The scenario still declares the browser by setting `type` to `'chromium'` inside that scenario's own `options.browser` block. - The run still emits the same `browser_*` metric family, including `browser_web_vital_lcp`, alongside the protocol-level metrics. - Page-level calls keep their names — only their return types changed from values to promises. ## Reading the failure The symptom is easy to recognise once you have seen it, and easy to misread if you have not: - The message is a **script exception**, so the run ends with no metrics and no summary worth reading. - It names `k6/browser` explicitly, which distinguishes it from a genuine resolution failure caused by a typo in the path. - It fires once per VU as each is built, so a run with many VUs can print it repeatedly before stopping. - No environment variable, CLI flag or compatibility mode brings the old path back. ## Finding it before CI does Because the failure happens at module instantiation, neither a syntax check nor a dry inspection of the script will surface it; you only see it when a VU is built. For a suite inherited from k6 v0.4x or v0.5x the cheapest first pass is to grep the whole repository for `k6/experimental/`, and for the browser module in particular the repair is mechanical: change the path to `k6/browser`, mark every exported browser entry function `async`, put `await` in front of each browser and page call, and re-check any assertion that used to be handed a value synchronously.

  • Why does k6 keep the removed path registered instead of just letting the import fail?
    So the failure can explain itself. A path k6 does not know at all produces a generic resolution error; the registered stub throws a message that names `k6/browser` as the replacement and points at the migration guide. It is a deliberate signpost, not a leftover.
  • A migrated script logs no error but crashes on `page.goto is not a function`. What went wrong?
    The import was fixed but the `await` was not. `browser.newPage()` returns a promise in k6 v2, so `const page = browser.newPage()` assigns the promise itself, and the next call fails because a promise has no page methods. Add `await` and mark the entry function `async`.
  • Does the graduation change how a browser scenario is configured?
    No. The scenario still switches the browser on with `type: 'chromium'` inside its own `options.browser` block, and the run still emits the `browser_*` metrics. Only the import path and the promise handling changed.

saying these in an interview costs you the question

  • Thinks k6/experimental/browser still works with a deprecation warning
  • Expects an alias to forward the old import to k6/browser
  • Claims the failure is a module-not-found resolution error
  • Changes only the import string and leaves the calls unawaited
  • Believes k6/browser needs a feature flag or a custom build
open as a page

In k6 v2, which module provides the gRPC client, and what happens if a script imports k6/experimental/grpc?

level: juniorimportance: must knowfreq 58%

basics

~20 s

k6 v2 ships its gRPC client at the stable path k6/net/grpc. The old k6/experimental/grpc name is registered only to throw a migration error on import, so the run aborts in the init phase before any VU starts.

open as a page

In k6, which scenario option enables a real browser, and where in the options object does it go?

level: middleimportance: must knowfreq 58%

basics

~10 s

Set type to 'chromium' in a scenario's own options.browser block, so the full path is options.scenarios.<name>.options.browser.type. It is per-scenario, there is no root-level browser option, and 'chromium' is the only value k6 v2 accepts.

open as a page

Which k6/net/grpc Client calls must run in the init context, and which are rejected there?

level: middleimportance: must knowfreq 62%

basics

~20 s

client.load() and client.loadProtoset() are init-only and error elsewhere with "load must be called in the init context". client.connect(), invoke(), asyncInvoke() and close() are the reverse: they raise an init-context error and must run inside an iteration.

open as a page

Which extra metrics does a k6 browser scenario emit, and why is http_req_duration not one of them?

level: middleimportance: should knowfreq 47%

basics

~10 s

A browser scenario adds nine metrics: five web vitals (browser_web_vital_lcp, fcp, ttfb, inp, cls) plus browser_data_sent, browser_data_received, browser_http_req_duration and browser_http_req_failed. Chromium issues the page traffic, so the core http_req_* metrics never see it.

open as a page

k6 v2 ships both k6/ws and k6/websockets as stable modules — how do the two APIs differ?

level: middleimportance: should knowfreq 46%

basics

~20 s

k6/ws exports a blocking ws.connect(url, params, callback) that parks the VU until the socket closes. k6/websockets exports a WebSocket constructor that returns at once and runs on k6's global event loop, so one VU can hold several sockets.

open as a page

In a k6 browser scenario, what closes the Chromium process, and why still call page.close()?

level: seniorimportance: should knowfreq 44%

basics

~20 s

k6 owns the browser: it launches one when an iteration starts and tears it down when the iteration ends, and the managed browser exposes no close() at all. Pages are yours — close each one, normally in a finally block.

open as a page

Why does a non-OK RPC from k6's client.invoke() not fail the iteration, and how do you catch it?

level: seniorimportance: should knowfreq 51%

basics

~20 s

In k6, client.invoke() returns a Response for any gRPC status, so a non-OK code is data rather than an exception. Compare response.status against grpc.StatusOK in a check; nothing in k6 classifies a gRPC status as a failure for you.

open as a page

How does a k6 script drive a Chromium browser that k6 did not launch itself?

level: seniorimportance: nice to knowfreq 31%

basics

~10 s

Import chromium from k6/browser and await chromium.connectOverCDP(wsEndpoint) with the browser's DevTools WebSocket URL. That scenario needs no options.browser block, because k6 launches nothing, and the browser it returns does expose close().

open as a page

Your k6 suite mixes k6/ws and k6/websockets scripts — which would you standardise on, and why?

level: principalimportance: nice to knowfreq 33%

basics

~20 s

Both are stable in k6 v2, so nothing forces the choice. A defensible line is: ban the deprecated k6/experimental/websockets alias immediately, default new scripts to k6/websockets, and port existing k6/ws scripts only when they are being rewritten anyway.

open as a page