Vitest Worker Pools: Threads, Forks, and VMs Explained
Vitest runs tests in parallel using worker processes. How those workers are created — and how isolated they are from each other — determines your test suite's speed, stability, and compatibility with native modules. Vitest offers three pool types: threads, forks, and vmThreads. Knowing which to use can cut your test time by 30-50% without changing a single test.
The Three Pool Types
threads (default)
Uses Node.js worker threads (worker_threads module). Workers share memory space with the main process but run in separate V8 isolates.
// vitest.config.ts
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
singleThread: false, // run each file in a separate thread
maxThreads: 8, // max concurrent workers
minThreads: 1, // keep at least 1 alive (warm start)
useAtomics: true, // use SharedArrayBuffer for sync (faster IPC)
},
},
},
});Best for: Pure JavaScript/TypeScript test suites. Fastest pool type — lowest overhead per test file.
Limitations:
- Native modules (
.nodefiles, database drivers likebetter-sqlite3) don't work reliably in worker threads - Some global state leaks between tests if not properly reset
- Modules with non-serializable state can cause subtle failures
forks
Uses Node.js child processes (child_process.fork()). Each worker is a completely separate OS process.
export default defineConfig({
test: {
pool: 'forks',
poolOptions: {
forks: {
singleFork: false, // isolate each file in its own process
maxForks: 4, // limit parallel forks
minForks: 0,
execArgv: ['--max-old-space-size=512'], // per-fork Node.js flags
},
},
},
});Best for:
- Tests that use native Node.js addons (
bcrypt,sqlite3,sharp) - Tests that rely on
process.envmutations between test files - Tests that leak global state you can't control
- Any test that crashes — a crashed fork doesn't kill the runner
Limitations: Higher overhead than threads — process spawn time (~200-500ms) per fork. Memory is not shared, so initialization code runs in every fork.
vmThreads
Like threads but runs each test file in its own V8 VM context using Node.js's vm module. Provides stronger isolation than plain threads.
export default defineConfig({
test: {
pool: 'vmThreads',
poolOptions: {
vmThreads: {
useAtomics: true,
memoryLimit: '512MB', // per-VM memory limit
},
},
},
});Best for:
- Tests that heavily mock modules (
vi.mock()) and need complete isolation - Tests that modify module registries or prototype chains
- Suites where different test files import different versions of the same module
Limitations: Slower than threads, and some Node.js built-ins behave differently inside VM contexts. The instanceof check can fail across VM boundaries.
Choosing the Right Pool
| Scenario | Recommended Pool |
|---|---|
| Pure TS/JS, no native modules | threads |
Uses better-sqlite3, bcrypt, sharp |
forks |
Heavy vi.mock() usage, module isolation needed |
vmThreads |
| Tests crash the runner | forks |
| Fastest possible speed | threads |
| Most isolation | forks |
Configuring Concurrency
All pools respect the maxWorkers / maxThreads / maxForks settings. The right number depends on your machine:
test: {
pool: 'threads',
poolOptions: {
threads: {
maxThreads: Math.max(1, os.cpus().length - 1), // leave one CPU free
},
},
}In CI environments, runners typically have 2-4 vCPUs. Going beyond the CPU count with CPU-bound tests hurts performance (context switching overhead). For I/O-bound tests (lots of await fetch()), you can go higher.
// For I/O-heavy tests (API calls, DB queries)
maxThreads: os.cpus().length * 2Project-Level Pool Configuration
Vitest supports multiple "projects" in a monorepo, each with its own pool:
export default defineConfig({
test: {
projects: [
{
name: 'unit',
include: ['src/**/*.unit.test.ts'],
pool: 'threads', // fast, no isolation needed
},
{
name: 'integration',
include: ['src/**/*.integration.test.ts'],
pool: 'forks', // each integration test gets a clean process
poolOptions: {
forks: { maxForks: 2 }, // limit to avoid DB contention
},
},
],
},
});Benchmarking Your Pool Choice
Measure which pool is actually faster for your specific test suite:
# Time each pool type
<span class="hljs-keyword">time npx vitest run --pool=threads 2>/dev/null
<span class="hljs-keyword">time npx vitest run --pool=forks 2>/dev/null
<span class="hljs-keyword">time npx vitest run --pool=vmThreads 2>/dev/nullOr use Vitest's built-in timing output:
npx vitest run --reporter=verbose 2>&1 | grep <span class="hljs-string">"Duration"Tuning for CI vs Local
CI runners and developer machines have different constraints. Use environment variables to configure pool size:
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
maxThreads: process.env.CI
? parseInt(process.env.VITEST_MAX_THREADS ?? '2')
: os.cpus().length - 1,
},
},
},
});GitHub Actions standard runners have 2 vCPUs. For faster CI, use larger runners:
jobs:
test:
runs-on: ubuntu-latest-4-cores # 4 vCPUs
env:
VITEST_MAX_THREADS: 4Disabling Parallelism for Debugging
When investigating test order dependencies or shared state issues, run serially:
# Run all tests in a single worker
npx vitest run --pool=forks --poolOptions.forks.singleFork=<span class="hljs-literal">true
<span class="hljs-comment"># Or with threads
npx vitest run --pool=threads --poolOptions.threads.singleThread=<span class="hljs-literal">trueSingle-worker mode reveals tests that depend on execution order — a sign of improper state cleanup.
Common Issues and Fixes
"Cannot use import statement in a module" in worker threads
Your Jest config may be leaking into Vitest. Check package.json for "type": "module" or "jest" config. Clean up Jest references.
Native module errors with threads
Switch to forks. Native .node modules use Node's addon system which doesn't always work across thread boundaries.
Tests pass in isolation but fail in parallel
State leakage. Use beforeEach/afterEach cleanup, or switch to forks for full isolation. Check for shared singletons, database connections, or global variables.
Memory growing across test files
With threads, workers are reused across files. Memory from one file's tests persists into the next. Limit reuse:
poolOptions: {
threads: {
isolate: true, // fresh module registry per file (default true)
},
}Summary
Vitest's threads pool is fastest for pure JS/TS suites. forks gives the most isolation and native module compatibility at the cost of startup time. vmThreads sits in between with VM-level isolation. Start with threads, switch to forks if you hit native module errors or state leakage, and tune maxThreads/maxForks based on your CI vCPU count. Measure before assuming — the fastest pool is the one that actually finishes first for your specific test mix.