skip to content

Transport Surface

Which protocols a k6 script can actually drive, and which import hands you each one. Interviewers probe it because k6's module paths moved and the old experimental ones now throw.

on this pageshow

explore

questions

15

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, how do you send a JSON body with http.post(), and what does a plain object send instead?

level: juniorimportance: must knowfreq 76%

basics

~10 s

k6 picks the encoding from the body's JavaScript type: a plain object is form-encoded as application/x-www-form-urlencoded. For JSON, pass JSON.stringify(payload) as the body and set Content-Type in the params headers object.

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 responses does k6 count as failed in http_req_failed, and how do you change that rule?

level: middleimportance: must knowfreq 64%

basics

~10 s

k6 treats statuses 200 through 399 inclusive as expected; anything else, including a transport error reported as status 0, is counted as failed. Change the rule with http.setResponseCallback(http.expectedStatuses(...)) or a per-request responseCallback param.

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

What argument forms does k6's http.batch() accept, and what does each form return?

level: middleimportance: should knowfreq 50%

basics

~20 s

http.batch() takes exactly one argument: an array or an object of requests. Each entry is a URL string, a positional array of method, url, body, params, or an object with those keys. Array in, array out; object in, object out.

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

In k6, what does http.get() return when the request times out or the host is unreachable?

level: seniorimportance: should knowfreq 55%

basics

~20 s

k6 returns a Response object rather than throwing. res.status is 0, res.body is null, and res.error and res.error_code carry the reason -- 1050 for a timeout, 1212 for a refused connection. A warning is logged and the iteration continues.

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

With discardResponseBodies enabled in k6, what is res.body, and how do you keep one response's body?

level: seniorimportance: nice to knowfreq 44%

basics

~10 s

The option flips every request's default responseType from text to none, so res.body comes back null and res.json() throws. Keep one body by passing responseType 'text' or 'binary' in that request's params object.

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