skip to content

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