skip to content

Why does a k6 test script that imports Node's fs module fail before any VU runs?

level: middleimportance: must knowfreq 72%

answer

  1. not Node, not a browser
  2. the VM is written in Go
  3. Sobek, compiled into the k6 binary
  4. bare specifiers never resolve
  5. no node_modules walk, no DOM

basics

~20 s

k6 executes JavaScript on Sobek, a JavaScript engine written in Go, not on Node.js. It ships no Node standard library and no node_modules resolution, so a bare specifier like fs never resolves and loading the script fails during init.

solid answer

~40 s

k6 is a single Go binary whose JavaScript is run by **Sobek**, an engine written in Go and compiled in. There is no Node process, no Node standard library, and no Node module-resolution algorithm. A specifier is only resolvable if it is a k6 built-in (`k6`, `k6/http`, `k6/x/...`), a fully specified relative or absolute file path, or an `https://` URL. `'fs'` matches none of those, so k6 reports that the module specifier could not be recognised, and it does so while loading the main module — before `options` is read and before a VU exists. Browser globals such as `window` are missing for the same reason: there is no DOM. Use `k6/http`, `__ENV`, `open()` and `k6/experimental/fs` instead, or bundle pure-JavaScript dependencies into one file first.

code

javascript · 14 lines
javascript
// import fs from 'fs';        // fails: bare specifier is not resolvable
// import os from 'os';        // fails: k6 has no Node standard library
// const w = window;           // fails: there is no DOM in a k6 script

import http from 'k6/http';
import { sleep } from 'k6';

export const options = { vus: 1, iterations: 1 };

export default function () {
  // __ENV replaces process.env, __VU replaces any thread identity
  http.get(`https://${__ENV.TARGET_HOST}/?vu=${__VU}`);
  sleep(1);
}

go deeper

for a junior

Remember the headline: k6 runs JavaScript on its own Go engine, so Node modules like fs and os and browser globals like window are simply not there. Reach for k6/http and __ENV instead.

for a middle

Be able to explain the mechanism: Sobek is the engine, module specifiers must be k6 built-ins, exact file paths or https URLs, and there is no node_modules walk or extension guessing, so a bare specifier cannot resolve.

for a senior

Show that you know when the failure happens and what it costs: module loading fails during init, before any VU starts, so a bad import is a fast, total failure in CI rather than a partial run. Talk about bundling as the practical remedy.

for a principal

Frame it as a dependency policy. Deciding whether a shared test library is written k6-native, bundled from npm, or pushed into a compiled extension determines who can maintain it and how test images are built and versioned.

## The engine underneath a k6 script k6 ships as a single Go binary, and the JavaScript inside it is executed by **Sobek** (`github.com/grafana/sobek`), a JavaScript interpreter written in Go and compiled into that binary. It is not V8, it is not a wrapper around a hidden `node` process, and in k6 v2 the older `goja` engine is not even a dependency any more. Everything a script can touch is therefore one of exactly two things: an ECMAScript built-in that Sobek itself implements, or something the k6 binary deliberately puts in the runtime. That one sentence explains almost every "but this works in Node" surprise. ## What "not Node" actually removes Two separate things are missing, and they are easy to conflate: 1. **The Node standard library.** `fs`, `os`, `path`, `net`, `child_process`, Node's `crypto` and Node's `http` do not exist, and neither do the `process` and `Buffer` globals. There is simply nothing to import. 2. **The Node module-resolution algorithm.** Even if a package sat on disk, k6 would not find it by bare name. k6 uses **browser-like resolution**: there is no `node_modules` walk, no `package.json` `main` lookup, and no extension guessing, so `./helpers` will not find `helpers.js`. Browser globals are absent for the same reason — there is no DOM, so `window`, `document`, `localStorage` and `XMLHttpRequest` are undefined in a k6 script. ## What actually happens at `import fs from 'fs'` The specifier `fs` is not `k6` and does not start with `k6/`, so k6 treats it as a path to resolve. It does not begin with `.` or `/`, it is not an absolute path, and it contains no `://`, so resolution runs out of options and fails with a message of the form: ``` The moduleSpecifier "fs" couldn't be recognised as something k6 supports. ``` This happens while k6 is loading the main module — **before the exported `options` object is read and before a single VU is initialised** — so the test never starts. `require('fs')` fails in exactly the same way: k6's `require()` is its own implementation sharing the same resolver, not Node's. | specifier written in a k6 script | what k6 resolves it to | |---|---| | `'k6'`, `'k6/http'`, `'k6/execution'` | a built-in module compiled into the binary | | `'k6/x/something'` | a module registered by a compiled-in extension | | `'./helpers.js'`, `'/abs/path/helpers.js'` | a file on disk, by its exact name | | `'https://example.com/lib.js'` | a remote script fetched over HTTPS | | `'fs'`, `'os'`, `'lodash'`, `'axios'` | **nothing** — bare specifiers do not resolve | ## What k6 hands you instead - `__ENV` in place of `process.env`, holding string values only. - `__VU` and `__ITER` in place of any thread or worker identity, with the `k6/execution` module as the richer form. - `open()`, usable only while the script initialises, in place of `fs.readFileSync`. - `k6/experimental/fs` for asynchronous file reads — a k6 module whose *name* happens to be `fs`, which does not make the bare specifier `'fs'` resolve. - `k6/http` in place of Node's `http`, `axios` or a `fetch` polyfill. - Standards-based globals the binary does register: `console`, `crypto` (Web Crypto), `setTimeout` and `setInterval`, and `TextEncoder`/`TextDecoder`. Code written against the web platform rather than against Node often runs unchanged. ## Getting existing JavaScript to run anyway 1. Reach for the k6-native module first; most of what a load test needs already lives under `k6/`. 2. If the library is pure JavaScript that touches no Node API, bundle it into one self-contained file with a bundler such as webpack, rollup or esbuild, and import that file by path. 3. If the library genuinely needs an operating-system capability, no bundler helps — that capability has to arrive from the Go side, as a compiled extension. ## What this is not It is not a flag or a version problem. `--compatibility-mode` does not add Node APIs; its only effect in k6 v2 is whether `global` is aliased to `globalThis`. It is not a TypeScript problem either: k6 strips types from `.ts` files, which has no bearing on whether `fs` exists. And although `k6/browser` drives a real Chromium, the k6 script itself still has no `window` — only the function bodies handed to `page.evaluate()` execute inside the browser page, where the DOM lives.

  • k6/browser drives a real Chromium. Does that give the k6 script a window object?
    No. The k6 script still runs in Sobek, where `window` and `document` are undefined. `k6/browser` controls a browser over its debugging protocol; only the function bodies you pass to `page.evaluate()` are shipped into the page and executed there, where the DOM actually exists.
  • How can an existing npm-published library be used from a k6 test at all?
    If it is pure JavaScript, bundle it and its dependencies into a single self-contained file with webpack, rollup or esbuild, then import that file by relative path. If it reaches for Node APIs, bundling will not save it — the capability has to come from the Go side as a compiled k6 extension, or be replaced by a k6 module.
  • Does --compatibility-mode=extended restore any Node APIs?
    No. In k6 v2 that mode adds exactly one thing over `base`: `global` defined as an alias for `globalThis`, so Node-oriented code that reads `global` does not crash immediately. It adds no modules, no `process`, no `Buffer` and no filesystem access.

It is like being handed a car whose dashboard controls you recognise instantly, in a country with different electrical sockets. The driving works; anything you try to plug into the wall has nothing to plug into.

saying these in an interview costs you the question

  • Thinks k6 embeds V8 or shells out to Node.js
  • Expects require('fs') to behave like Node's require
  • Assumes k6 searches node_modules for bare imports
  • Believes window and document exist in a k6 script
  • Says you can npm install a package into k6 directly
  • Blames the compatibility-mode flag for the missing APIs