Vitest Workers: one workerd per test file, 8.08s to 5.08s
A Workers Vitest suite boots one workerd instance per test file, and that boot is invisible in Vitest's own summary line — so a suite whose assertions take milliseconds still pays half a second of runtime startup per file. Counting pgrep -x workerd during a serialised run of 14 test files returns exactly 14. Move the files that do not touch a Workers API out of the Workers pool and the same suite spawns 6, with wall clock down from 8.08 s to 5.08 s at default settings.
I measured this on a 4-vCPU arm64 box (Node 26.5.1, Vitest 4.1.11, @cloudflare/vitest-plugin 1.1.9, workerd 2026-09-11) with a suite of 6 integration files against a Hono Worker plus 8 unit files over its plain-TypeScript helpers — the mixed layout most Workers repositories drift into. Every figure below comes from that run, three runs per cell, medians quoted. If you still depend on @cloudflare/vitest-pool-workers, the older package name, the accounting is identical; it exposes the same pool options and Cloudflare's documentation now points at the @cloudflare/vitest-plugin Vite plugin.
TL;DR
- One test file, one workerd process. A serialised run of 14 files spawns 14 runtimes. After the split below it spawns 6 — one per integration file, none for the unit files.
- The per-file cost is not in the summary buckets. A unit file that imports no Cloudflare API at all takes 728 ms of wall clock inside the pool and 220 ms in a plain Node project. About 650 ms of the 728 ms is neither
transform,import,testsnorenvironment— the line does not show it, and 14 files pay it 14 times. - Do not trust the
importbucket as your signal. The same integration file reportsimport 1.28 s / tests 13 mswith the deprecatedSELFbinding andimport 37 ms / tests 1.24 swith the recommendedexports.default.fetch()— identical 1.96 s wall clock, identical work, different buckets. Wall clock and process count are the measurements that survive. SELFandenvfromcloudflare:testare deprecated in@cloudflare/vitest-plugin1.1.9; the shipped types point atimport { env, exports } from 'cloudflare:workers'andexports.default.fetch(). Both work — I ran them side by side.- The fix is a Vitest project split, not a pool option. Only integration files stay in the Workers pool. Wall clock at default settings: 8.08 s → 5.08 s (−37.1%), runtimes 14 → 6.
- Raising
maxWorkersbuys contention, not throughput. 1 → 8 cuts wall clock from 12.74 s to 7.10 s while multiplying the aggregate test-phase work by 4.06× (4.22 s → 17.14 s) on four cores. --isolate=falseis the trap. It collapses 14 runtimes into 1 and 12.74 s into 2.42 s — and it breaks test isolation. My four-file probe: 4/4 pass with isolation, 2 failed / 2 passed without it (expected 1 to be +0,expected [ Array(1) ] to deeply equal []).
What the plugin spawns for every test file
The Workers integration installs itself as a Vitest pool runner. Vitest creates one pool worker per test file — that is exactly what isolate: true, the default, buys you — and the pool worker starts a Miniflare instance as it comes up. In @cloudflare/vitest-plugin 1.1.9 that is the createPoolWorker hook returning new CloudflarePoolWorker(options, poolOptions), whose start() awaits getProjectMiniflare(...) before it connects to the runtime socket. One pool worker in, one workerd process out.
You do not have to read the source to see it. Count the processes. The sampler below runs the suite once and records every workerd PID it observes; pgrep -x matches the exact process name, so the shell running the sampler is not counted by accident — an earlier version using pgrep -f reported 91 "workerd" matches, all of them the harness.
#!/usr/bin/env bash
# pids.sh — count how many workerd processes one suite run spawns.
# usage: bash pids.sh <vitest-config> [maxWorkers] [extra flags]
set -uo pipefail
( while true; do pgrep -x workerd; sleep 0.15; done ) > pids.txt &
SAMPLER=$!
./node_modules/.bin/vitest run --config "${1:-vitest.config.ts}" --maxWorkers="${2:-1}" ${3:-} \
| grep -E '^\s+(Test Files|Tests|Duration)'
kill "$SAMPLER" 2>/dev/null || true
echo -n 'distinct workerd processes: '
sort -u pids.txt | wc -l
Against the single-pool config, bash pids.sh vitest.config.ts 1 prints the run summary and the count:
Test Files 14 passed (14)
Tests 27 passed (27)
Duration 12.67s (transform 738ms, setup 0ms, import 1.15s, tests 4.23s, environment 2ms)
distinct workerd processes: 14
Fourteen test files, fourteen runtimes. Nothing here is a bug in the plugin: per-file isolation is the documented behaviour, and it is what makes a leaked module-scope cache from file A unable to corrupt file B. The problem is the price, not the policy — and the price is hidden, because a passing suite with an 8-second wall clock reads as "the tests are slow" rather than "14 runtimes booted to run 27 assertions".
Where the cost actually hides
Take one unit file out of the suite and run it alone, twice — once in the Workers pool and once in a plain Node project. It imports Zod schemas and nothing else; it never references cloudflare:test, cloudflare:workers, a binding or a runtime global.
| Single unit file, isolated run | Wall clock | transform | import | tests |
|---|---|---|---|---|
| Inside the Workers pool | 728 ms | 28 ms | 43 ms | 5 ms |
| In the Node project | 220 ms | 33 ms | 49 ms | 12 ms |
The buckets are near-identical — the Node run even spends marginally more in import and tests. The 508 ms difference is wall clock that no bucket claims: it is the workerd instance being started and torn down for a file that will never call into workerd. Multiply it by the eight unit files in this suite and the pool is spending roughly four seconds on tests that do not need the runtime at all.
The same distortion shows up in the import bucket, which is why I stopped using it as the primary signal while writing this. One integration file, run alone at maxWorkers=1, with the two API styles in turn:
| Integration file, single run | Wall clock | import | tests |
|---|---|---|---|
import { SELF } from 'cloudflare:test' (deprecated) | 1.97 s | 1.28 s | 13 ms |
import { exports } from 'cloudflare:workers' (recommended) | 1.95 s | 37 ms | 1.24 s |
Same file, same two tests, same runtime, same wall clock to within 20 ms — and 1.24 s of work moves from one bucket into the other. With the deprecated service binding the pool materialises the Worker's module graph during the module-import phase; with exports.default.fetch() it happens on first call, inside the test. Either way you pay it. Optimise against wall clock and process count, and treat the bucket split as an accounting detail, not a diagnosis.
The deprecation itself is worth knowing before you copy an older tutorial: in 1.1.9 both bindings carry the marker in the shipped type declarations, SELF pointing at exports.default.fetch() and env at import { env } from "cloudflare:workers". The rest of this article uses the current API.
Measure the boot cost on your own suite
Two things have to be true before this matters to you: your files each import a real module graph, and some of your files do not need workerd at all. The lab below is the smallest honest version of a production Workers project — a Hono app with route modules, Zod validation, KV access and security headers, driven through the Worker's own fetch handler — plus eight unit files covering its runtime-agnostic helpers.
The config that produces the tax is the one almost every Workers repository starts from: the plugin registered on the root config, with a single include covering the whole test tree.
// vitest.config.ts — one pool: every test file, unit or integration, runs in workerd.
import { defineConfig } from 'vitest/config';
import { cloudflareTest } from '@cloudflare/vitest-plugin';
export default defineConfig({
plugins: [
cloudflareTest({
main: './src/worker.ts',
miniflare: {
compatibilityDate: '2026-08-01',
kvNamespaces: ['CACHE'],
},
}),
],
test: {
include: ['test/**/*.test.ts'],
testTimeout: 10_000,
},
});
Dependencies are pinned to the versions measured here, so the buckets below are reproducible:
{
"name": "workers-vitest-lab",
"private": true,
"version": "1.0.0",
"type": "module",
"dependencies": {
"hono": "4.13.8",
"zod": "4.6.5"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.1.9",
"vitest": "4.1.11"
}
}
Install, then run the suite with parallelism pinned to one worker so the counters are not mixed across processes:
npm install --no-audit --no-fund
./node_modules/.bin/vitest run --config vitest.config.ts --maxWorkers=1
On this suite the summary comes back like this:
Test Files 14 passed (14)
Tests 27 passed (27)
Duration 12.76s (transform 737ms, setup 0ms, import 1.16s, tests 4.25s, environment 1ms)
Note what the line does and does not say. transform + import + tests accounts for 6.15 s of a 12.76 s wall clock. The remaining 6.6 s is pool startup, teardown and the per-file runtime boot examined above — the work that scales with your file count and never appears in a bucket. That is why the process counter matters more than the ratio: if your count equals your file count, every file is paying a boot.
More workers make the bill worse, not better
The obvious reaction is to raise parallelism. On a fixed-size runner that is the wrong move, and the numbers show why. Medians of three runs each, the same 14 files on four cores:
| maxWorkers | Wall clock | Aggregate test phase | Aggregate import | workerd processes |
|---|---|---|---|---|
| 1 | 12.74 s | 4.22 s | 1.17 s | 14 (one per file) |
| 4 | 7.49 s | 9.23 s | 2.26 s | 4 concurrent |
| 8 | 7.10 s | 17.14 s | 5.17 s | 8 concurrent |
| default (4 here) | 8.08 s | 6.88 s | 2.40 s | 4 concurrent |
Read the second and third columns together. Going from 1 worker to 8 cuts wall clock from 12.74 s to 7.10 s — and multiplies the aggregate test-phase work by 4.06×, because each of the 14 runtimes still does its own setup and now they compete for four cores. Those aggregate buckets overlap in wall-clock time; that is the point. 17.14 s of engine work to run 4.22 s of assertions is a scheduling problem, not a slow-test problem, and the plateau is the signature: past maxWorkers=4 you are paying roughly twice the aggregate work for 0.4 s.
The same ceiling shows up in production Workers as the per-request CPU budget — the reason an Error 1102 CPU time limit appears under load long before an error counter moves. In CI the budget is your runner's vCPUs, and workerd instances competing for them lose the same way user requests do.
The fix: keep the Node tests out of the pool
The unit files in this suite test Zod schemas, RFC 9457 error shaping, cursor encoding, backoff maths, ETag handling and slug generation — logic with no Workers API in it at all. They were in the Workers pool only because the config had exactly one pool. Splitting them into a plain Node project removes a workerd boot per file, and keeps per-file isolation for every test that actually needs it.
// vitest.config.ts — Node for the unit files, the Workers pool for the integration files.
import { defineConfig } from 'vitest/config';
import { cloudflareTest } from '@cloudflare/vitest-plugin';
export default defineConfig({
test: {
projects: [
{
plugins: [
cloudflareTest({
main: './src/worker.ts',
miniflare: {
compatibilityDate: '2026-08-01',
kvNamespaces: ['CACHE'],
},
}),
],
test: {
name: 'workers',
include: ['test/workers/**/*.test.ts'],
testTimeout: 10_000,
},
},
{
test: {
name: 'unit',
environment: 'node',
include: ['test/unit/**/*.test.ts'],
},
},
],
},
});
The plugin goes on the project that needs the runtime, never on the root config — that is the part people get wrong when they try this by widening a single include. Rerun the same four worker settings:
| maxWorkers | Wall clock | Aggregate test phase | Wall clock change vs single pool |
|---|---|---|---|
| 1 | 9.40 s | 4.38 s | −26.2% |
| 4 | 5.25 s | 7.82 s | −29.9% |
| 8 | 4.65 s | 11.80 s | −34.5% |
| default (4 here) | 5.08 s | 6.66 s | −37.1% |
The process count is the clearest evidence that work was removed rather than shuffled: bash pids.sh vitest.split.config.ts default now reports 6 distinct workerd processes for the same 14 files, and all 27 tests still pass.
Test Files 14 passed (14)
Tests 27 passed (27)
Duration 5.23s (transform 1.87s, setup 0ms, import 632ms, tests 7.08s, environment 2ms)
distinct workerd processes: 6
Two caveats worth stating plainly. First, the win scales with how much of your suite does not need workerd: a repository where 30 of 34 files are integration tests will see far less than a third. Second, the numbers above are for a small module graph (Hono plus Zod) — eight unit files here remove about four seconds of boots. Your per-file constant is what matters, and the one-file experiment at the top gives it to you in two commands.
The same experiment on @cloudflare/vitest-pool-workers 0.22.0 — the older package name, same WorkersPoolOptions schema — reproduced the shape: 14 processes at maxWorkers=1, one per file, and the same direction of travel after the split. Whichever name is in your package.json, the accounting is the same.
The shortcut that breaks your suite: --isolate=false
Since the cost is per-file isolation, disabling it is the tempting lever, and it is not hidden: it is Vitest's own isolate option, which defaults to true. It works, it is dramatic, and it silently changes what your tests mean.
# same 14 files, same config, isolation off
bash pids.sh vitest.config.ts 1 --isolate=false
Test Files 14 passed (14)
Tests 27 passed (27)
Duration 2.42s (transform 730ms, setup 0ms, import 151ms, tests 1.46s, environment 0ms)
distinct workerd processes: 1
12.74 s becomes 2.42 s and fourteen runtimes become one, because all 14 files now share a single pool worker — and therefore a single module registry and a single Miniflare storage stack. To find out what that costs, I wrote four probe files that each assert the isolation they assume. The module one imports a deliberately stateful helper:
/** Module-scope mutable state: deliberately stateful, to observe module reuse. */
let count = 0;
export function bump(): number {
count += 1;
return count;
}
export function current(): number {
return count;
}
Both probe files are byte-identical and assert a fresh registry, so the probe cannot pass just because the runner happened to order the files a certain way. The first probe to run sees 0 and bumps to 1; the second one is the test:
import { describe, expect, it } from 'vitest';
import { bump, current } from '../src/lib/counter';
describe('module registry', () => {
it('starts this test file from a fresh module registry', () => {
expect(current()).toBe(0);
expect(bump()).toBe(1);
});
});
A second pair does the same for storage, listing the item collection, asserting the namespace is empty, and creating one item — through the Worker itself, so it exercises the real handler and the real KV binding rather than an in-process map:
import { exports } from 'cloudflare:workers';
import { describe, expect, it } from 'vitest';
describe('KV namespace', () => {
it('starts this test file with an empty namespace', async () => {
const before = (await (await exports.default.fetch('https://example.com/items?limit=100')).json()) as {
items: string[];
};
expect(before.items).toEqual([]);
const created = await exports.default.fetch('https://example.com/items', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Leaky', price_cents: 100 }),
});
expect(created.status).toBe(201);
});
});
Point a probe config at that directory — the plugin on the root config again, with the include narrowed to the probes — and run it both ways:
// vitest.probe.config.ts — isolation probe only; pair it with --isolate=false.
import { defineConfig } from 'vitest/config';
import { cloudflareTest } from '@cloudflare/vitest-plugin';
export default defineConfig({
plugins: [
cloudflareTest({
main: './src/worker.ts',
miniflare: {
compatibilityDate: '2026-08-01',
kvNamespaces: ['CACHE'],
},
}),
],
test: {
include: ['probe/**/*.test.ts'],
testTimeout: 10_000,
},
});
Then run the probe twice — once with Vitest's default isolation, once with it disabled:
./node_modules/.bin/vitest run --config vitest.probe.config.ts --maxWorkers=1
# Test Files 4 passed (4) Tests 4 passed (4) Duration 4.23s
./node_modules/.bin/vitest run --config vitest.probe.config.ts --maxWorkers=1 --isolate=false
# FAIL probe/kv-state-a.test.ts > KV namespace > starts this test file with an empty namespace
# AssertionError: expected [ Array(1) ] to deeply equal []
# FAIL probe/module-state-b.test.ts > module registry > starts this test file from a fresh module registry
# AssertionError: expected 1 to be +0 // Object.is equality
# Test Files 2 failed | 2 passed (4)
Two of four files failed, and the failing pair moves with the run order — in a repeat run the other file of each pair was the one to fail, which is exactly how this lands in a real repository: green locally, red in CI, or worse, green in both while two tests quietly depend on each other. The KV result is the one that should stop you reaching for the flag on a Workers project: per-file isolated storage is a headline feature of the integration, and --isolate=false gives it up, so rows written by one file are visible to the next.
If you have a suite that is genuinely isolation-agnostic — pure functions, no module-scope state, no bindings — turn it off there, in its own project, and take the wall-clock cut. Do not apply it to the project that holds your integration tests, and do not apply it as a root-level flag because the suite looked slow. The split above keeps isolation everywhere and still removes two-thirds of the runtimes.
What to do in your repository this week
- Run the sampler before you change anything.
bash pids.sh vitest.config.ts 1. If the count equals your file count, every file is paying a runtime boot; if it is much lower, your suite is already split or your files are being reused. - Time one unit file in both projects. It takes two commands and it gives you the exact per-file cost on your hardware — the number that tells you whether the split is worth an afternoon.
- Split by capability, not by directory cosmetics. The test is "does this file touch a binding, a Workers global, or
cloudflare:workers?" Everything else belongs in the Node project — validation, formatting, pricing logic and state machines that happen to live in your Worker repository. - Keep
isolate: trueon the Workers project. Reuse is what breaks per-test storage and module registries; the split already removes the cost it was meant to remove. - Migrate off
SELFandenvfromcloudflare:test. Both are deprecated in 1.1.9 in favour ofexports.default.fetch()andenvfromcloudflare:workers, and the swap changes which bucket the runtime work lands in — so re-baseline any dashboard that watchesimport. - Treat the runtime boot as a fixed per-file cost and budget files accordingly. Consolidating three small integration files into one well-named file is a legitimate optimisation. The resource ceilings that bite dev containers apply to CI runners too — process count is one of them.
Related work on this blog
- Cloudflare Error 1102: CPU time limit exceeded — the same fixed-budget arithmetic, applied to requests instead of CI runners.
- System limit for number of file watchers reached in Docker — process and file-descriptor ceilings when the same repository is built in a container.
- Cloudflare Error 1101: Worker threw exception — what an unhandled error in a Worker looks like from the caller's side, and why integration tests catch it before production does.
All timings, process counts and failure output in this article were produced by execution on 2026-09-15 on a 4-vCPU arm64 host: Node 26.5.1, Vitest 4.1.11, @cloudflare/vitest-plugin 1.1.9, workerd 2026-09-11, 14 test files / 27 tests, three runs per table cell with medians quoted and single runs quoted where the summary line is shown. The split-project shape was re-measured on @cloudflare/vitest-pool-workers 0.22.0 on the same machine. The two-API comparison in the second table is the same file run under both bindings on that machine.