skip to content

Why does adding a new protocol to k6 require a compiled custom binary rather than a plugin file?

level: middleimportance: must knowfreq 72%

answer

  1. linked in, not loaded at runtime
  2. one Go binary, no plugin folder
  3. xk6 build --with
  4. modules.Register inside init()

basics

~10 s

k6 extensions are Go code compiled into the k6 binary; k6 has no runtime plugin loader. Either k6 provisions a prebuilt binary for you automatically, or you build one yourself with xk6 build --with.

solid answer

~40 s

k6 is a single statically linked Go binary, and an extension is a Go package linked into it — a JavaScript extension calls `modules.Register("k6/x/mqtt", ...)` in an `init()` function, so the binary owns that module name. There is no directory k6 scans for plugins at startup. To add a protocol k6 does not speak you therefore need a binary containing the extension: in k6 v2 you either let automatic extension resolution fetch one from the build service, or you run `xk6 build --with github.com/…/xk6-mqtt`, which drops a `./k6` in the current directory. The practical consequence is that the test is only runnable where that binary is.

code

bash · 7 lines
bash
go install go.k6.io/xk6/cmd/xk6@latest

xk6 build latest \
  --with github.com/grafana/[email protected] \
  --output k6-extended

./k6-extended run script.js

go deeper

for a junior

Remember the one-line shape: a k6 extension is Go code compiled into the k6 binary, imported from a name starting with k6/x/. There is no plugin folder to copy a file into.

for a middle

Be able to walk both routes to that binary — automatic extension resolution on k6 run, and xk6 build --with — and explain that registration happens in a Go init() function that claims the k6/x/ module name.

for a senior

Show you have shipped one: pin the extension version in the build, know that ./k6 is not the k6 on PATH, and plan how the extended binary reaches every developer and CI runner that must execute the test.

for a principal

Frame it as a distribution decision rather than a build trick. Adding a protocol widens who must hold a special binary, so weigh that ongoing cost against writing the interaction on top of what k6 already speaks.

## What "extension" means in k6 k6 ships as a **single, statically linked Go binary**. There is no plugin directory it scans at startup, no shared object it dlopens, and no package manager that adds capability to an installed copy. An extension is a **Go package that is compiled into the binary**, and it announces itself during Go's package initialisation. A JavaScript extension calls `modules.Register` from `go.k6.io/k6/v2/js/modules` inside an `init()` function; from that moment the binary owns a module name such as `k6/x/mqtt`, and any script that binary runs can `import` it. That is why the interesting question about a k6 extension is never "where do I put the file" but **"which binary am I running"**. In k6 v2 the answer decides who on your team, and which CI job, can execute the test at all. ## The worked case: a protocol k6 does not speak Suppose your service is reached over a protocol that k6's built-in modules do not cover. You cannot express it in JavaScript on top of `k6/http`, so you reach for a `k6/x/` extension. You now have exactly two routes to a binary that contains it. | route | what you run | what it needs | when it fits | |---|---|---|---| | **automatic extension resolution** | `k6 run script.js` unchanged | network access to the k6 build service; the extension must be one the service can build | the extension is published and you want zero setup | | **`xk6 build`** | `xk6 build --with <module>` then `./k6 run script.js` | a Go toolchain (or the xk6 build image) | your own or an unpublished extension, or you need a fixed artifact | Both produce the same thing: **a k6 binary with the Go code linked in**. They differ only in who performs the link step and where the result lands. ## Building one yourself with xk6 `xk6` is a separate tool, installed with `go install go.k6.io/xk6/cmd/xk6@latest`. Its build command takes an optional k6 version and a repeatable `--with` flag: ```bash xk6 build latest \ --with github.com/grafana/[email protected] \ --output k6-extended ``` - **`--with <module[@version][=replacement]>`** adds one extension. Repeat it for each one; omitting `@version` means `latest`, so pin it if you care about reproducibility. - **`--output <file>`** names the binary. Without it you get `./k6` **in the current directory** — which is *not* the `k6` on your `$PATH`, so you must run `./k6 run script.js` deliberately. - **`--replace <module=replacement>`** swaps a dependency; treat it as an advanced escape hatch. - Pointing `--with github.com/grafana/xk6-sql=.` at the current directory builds your local checkout, which is how you test an extension you are writing. ## The route where k6 does the linking for you In k6 v2 you often do not have to run `xk6` at all. **Automatic extension resolution** is on by default: k6 reads the script, notices that a `k6/x/` module is not present in the running binary, asks the k6 build service for one that is, caches it, and **re-executes that binary as a subprocess** with your original arguments. From the outside it looks like the stock `k6` grew a new protocol; underneath, a second, differently-compiled binary did the work. The model has not changed — only who performed the build. It serves published extensions; anything the build service cannot produce still needs `xk6`. ## Confirming what a binary actually carries Because the capability is baked in, you can interrogate the binary directly: 1. `k6 --version` prints an **`Extensions:`** block listing each extension as its module path, version, registered name and type. 2. `k6 version --json` prints the same information structurally, with a `module`, `version` and the `imports` or `outputs` each module registers. 3. `k6 deps script.js` reads the script instead of the binary and ends with **`Custom Build Required: yes` or `no`** for the binary you invoked it with. ## What the design costs you - Every machine that runs the test needs **the same extended binary**, not just the same script. A colleague with a stock `k6` gets a module-not-found failure, not a slower run. - A name registered by an extension is **global to that binary**: registering the same name twice panics at startup, and a JavaScript extension name that does not begin with `k6/x/` panics too. - Upgrading k6 means rebuilding, because the extension is bound to the k6 version it was compiled against. - The upside is real: the extension runs as **native Go**, sharing k6's scheduler, metrics registry and output pipeline rather than sitting behind an interpreter boundary. A protocol client written this way is not an interpreted shim over sockets; it is compiled code holding its own connections inside the same process that schedules your VUs. In k6 v2 the automatic route hides most of this for published extensions, but the underlying model is unchanged — you are always running *some* compiled binary, and the only question is whether you built it or k6 fetched it for you.

  • How do you check whether the k6 binary in front of you already contains an extension?
    `k6 --version` prints an `Extensions:` block naming each compiled-in extension's module path, version, registered name and type, and `k6 version --json` returns the same structurally. To ask the question from the script's side instead, `k6 deps script.js` ends with `Custom Build Required: yes` or `no` for the binary you ran it with.
  • What does xk6 build do differently when you omit --output?
    It writes the binary as `k6` into the current working directory. That file is not the `k6` on your `$PATH`, so you must invoke it explicitly as `./k6 run script.js`; forgetting the `./` silently runs the stock binary, which then fails to resolve the `k6/x/` import.
  • Why can two extensions not register the same name in one k6 binary?
    Registration writes into a single process-wide registry keyed by extension type and name, and a duplicate registration panics during startup rather than picking a winner. The same guard rejects a JavaScript extension whose name is missing the required `k6/x/` prefix, so both faults surface immediately as a crash, not as a confusing runtime import.

It is a tow bar welded on at the factory, not a roof rack you clip onto the car you already own. The capability travels with the vehicle, so everyone who needs it needs that vehicle.

saying these in an interview costs you the question

  • Thinks k6 scans a plugins directory for extensions at startup
  • Believes npm install can add a k6/x/ module to k6
  • Assumes any installed k6 binary can run any k6 script
  • Confuses a plain JavaScript helper library with a compiled extension
  • Expects xk6 build to replace the k6 already on PATH