skip to content

Stable Versus Experimental

Which k6 module paths are stable, which three are still experimental, and which now throw on import because the module graduated or left core. Asked because copied scripts carry dead paths.

on this pageshow

explore

questions

5

Why does importing k6/experimental/browser abort a k6 v2 run, and what replaces it?

level: juniorimportance: must knowfreq 71%

answer

  1. the old path is not simply missing
  2. registered, but bound to a throwing stub
  3. fails while loading, not mid-iteration
  4. the message names its own replacement

basics

~20 s

The browser module graduated to k6/browser, and k6 v2 keeps the old path registered only as a stub that throws a migration message. The run dies while the script is loading and k6 exits 107.

solid answer

~40 s

`k6/experimental/browser` is no longer a module in k6 v2. The name is still in k6's module registry, but it is bound to a placeholder whose only behaviour is to throw an error saying the module has graduated and to use `k6/browser` instead. Because module instantiation happens while the script is being loaded, the failure lands before any VU runs an iteration -- the whole run dies at startup rather than one iteration failing -- and k6 exits **107**, its script-exception code. The fix is an edit to the import specifier and nothing else: graduation moved where the module is registered, not what it exports. `k6/experimental/grpc` behaves identically and points you at `k6/net/grpc`.

code

javascript · 7 lines
javascript
// k6 v2: the experimental paths throw; these are the graduated core paths
import { browser } from 'k6/browser';
import grpc from 'k6/net/grpc';

export default function () {
  console.log(typeof browser, typeof grpc);
}

go deeper

for a junior

Recognise the error on sight and know the fix is an import edit to k6/browser. The message k6 prints already names the replacement, so the skill is reading it rather than guessing.

for a middle

Explain that the old name is still registered but bound to a throwing stub, and that the throw happens during script load, which is why no iteration runs and no summary appears.

for a senior

Demonstrate that you would grep the script tree for the prefix rather than discovering dead paths one failed run at a time, and that you know the rewrite is not mechanical.

for a principal

Consider why k6 chose a loud migration message over a silent alias, and what that implies for how your team schedules k6 major upgrades across a shared script library.

## What "graduated" means in k6 A module that starts life under `k6/experimental/` and proves itself is **graduated**: k6 re-registers it under a core import path and it joins the set of modules covered by the project's stability guarantee. The browser module went that way -- it is now `k6/browser`. Graduation is not a rewrite; it is a change of address. The exported API you get from `k6/browser` is the same one the experimental path used to hand you. The catch is that the old address does not silently keep working forever. k6 keeps the graduated path around as a deprecated alias for a while, then deletes it at the next major release. k6 v2.0.0 was that release for the browser module. ## Why you get a helpful error instead of "module not found" Deleting the path could have meant k6 simply failing to resolve it. Instead the name is still in k6's module registry -- deliberately -- bound to a stub module whose single job is to raise an error carrying a migration message: - the message states that the module **has been graduated**; - it names the replacement path, `k6/browser`; - it links the migration guide. That is why the failure is diagnosable in one read. It is also worth knowing the contrast: a path k6 has *never* registered, such as an extension path on a stock binary, produces a different error that lists the unrecognised module names and suggests a custom binary. A migration message means "this used to exist here"; an unknown-modules error means "k6 has no record of this name at all". ## Where in the run the failure happens k6 evaluates a script's init context before it starts any VU iteration, and instantiating the module graph is part of that. The stub throws at instantiation. Consequences: 1. Nothing in your `default` function ever runs, and no metric is emitted. 2. `setup()` and `teardown()` never run either -- the run does not get that far. 3. You get **one** failure per run: the load aborts at the first removed path k6 instantiates, so three dead imports in one script take three fix-and-rerun cycles if you work that way. 4. The process exits **107**, the script-exception code, which is what k6 uses for any error raised while the script is being loaded or executed. It is not the threshold code. ## The fix, and the ones that are not this simple | old path | replacement | edit | |---|---|---| | `k6/experimental/browser` | `k6/browser` | change the specifier | | `k6/experimental/grpc` | `k6/net/grpc` | change the specifier | Both of these are one-line edits, and both keep the same imported bindings -- `import { browser } from 'k6/browser'` and `import grpc from 'k6/net/grpc'`. What makes the browser case a trap is that people generalise it. A mechanical "drop the `experimental/` segment" rewrite happens to be right for the browser module and wrong for most of the rest: `k6/experimental/grpc` does **not** become `k6/grpc`, and `k6/experimental/webcrypto`, `k6/experimental/tracing` and `k6/experimental/redis` do not become `k6/webcrypto`, `k6/tracing` or `k6/redis` either. Read the message k6 prints; it names the correct destination every time. ## Reading the error in practice The whole diagnosis is three observations: - **The run failed instantly**, with no iterations and no summary metrics -- so it failed while loading, not while running. - **The message names a module path**, which narrows it to an import. - **The message names a replacement**, which means the path was registered-and-removed rather than never known. Change the specifier, rerun, and if a second dead path is hiding behind the first you will see it on the next run. If you would rather not iterate, grep the script tree for `k6/experimental/` before you run anything -- a static scan finds all of them at once, while the runtime only ever tells you about the first. ## Why so many scripts still carry the old path This is the most common first failure a team hits when it moves to k6 v2, and the reason is supply-side. The browser module lived at `k6/experimental/browser` for a long run of v0.4x and v0.5x releases, which is the era most of the copyable material was written in: tutorials, blog posts, conference examples, sample repositories and any script generated from a model trained on them. All of that names the experimental path, and none of it stops being findable when k6 ships a major. So treat a script you did not write as suspect on this point specifically. The check costs one grep, the failure costs a confusing run, and the fix is a specifier. The same reasoning applies to `k6/experimental/grpc`, which had an identical history and produces an identical failure.

  • Which exit code does k6 return when a removed module import throws?
    107, the script-exception code. The stub throws while k6 instantiates the module graph, which happens while the script is being loaded, so the run never reaches the default function and never produces a summary.
  • Does k6 report every removed import in a script at once, or one at a time?
    One at a time. The throw aborts the load as soon as k6 instantiates the first removed path, so a fix-and-rerun loop clears them one per run. Names k6 does not recognise at all behave differently: those are collected and reported together in a single error.
  • Is the k6/browser API different from what k6/experimental/browser exported?
    No. Graduation moved where the module is registered, not what it exports -- the experimental path was an alias for the same code. Changing the specifier to `k6/browser` is the whole migration as far as the import is concerned.

saying these in an interview costs you the question

  • Says k6/experimental/browser still works if you pass a flag
  • Expects a module-not-found error rather than a migration message
  • Thinks a bad import fails only the iteration that ran it
  • Believes a failed import still lets the summary and exit code look clean
  • Assumes k6/browser exports a different API from the old path
open as a page

In k6 v2, which module paths still live under k6/experimental/, and what does that prefix mean?

level: middleimportance: must knowfreq 62%

basics

~20 s

k6 v2 ships exactly three experimental modules: k6/experimental/csv, k6/experimental/fs and k6/experimental/streams. The prefix marks a module deliberately left outside k6's stability guarantee, so it may change in a minor release; every other importable path is core.

open as a page

In k6 v2, why does k6/experimental/websockets only warn while k6/experimental/grpc throws?

level: middleimportance: should knowfreq 48%

basics

~20 s

k6/experimental/websockets is deprecated, not removed: k6 v2 registers it as a wrapper that logs one notice and then hands back the same websockets implementation that k6/websockets exposes. k6/experimental/grpc is registered only to a throwing stub.

open as a page

How would you find and fix every dead k6 module import in scripts written for k6 v0.5x?

level: seniorimportance: should knowfreq 55%

basics

~20 s

Scan the sources for k6/experimental/ rather than discovering the paths one failed run at a time, then classify each hit by hand. k6 v2's six removed paths lead to four different destinations, so a blanket rename is wrong for most of them.

open as a page

Should a team standardise on a k6/experimental module, and how would you limit the blast radius?

level: principalimportance: nice to knowfreq 34%

basics

~20 s

Yes when no core k6 module does the job, no when one does. k6 excludes experimental paths from its stability allowlist, so contain the exposure: pin the k6 version and funnel the import through one shared local module.

open as a page