[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

perf: make the Node compile cache opt-in, persist worker caches on teardown (#10742)

authored by

Vladimir and committed by
GitHub
(Jul 9, 2026, 8:21 AM +0200) 941bc836 96fa6d73

+139 -18
+12
docs/guide/improving-performance.md
··· 91 91 Duration 5.90s (transform 842ms, setup 543ms, import 2.35s, tests 2.94s, environment 0ms, prepare 3ms) 92 92 ``` 93 93 94 + ## Node Compile Cache 95 + 96 + Vitest supports Node's [on-disk compile cache](https://nodejs.org/api/cli.html#node_compile_cachedir): when the `NODE_COMPILE_CACHE` environment variable points at a directory, the V8 bytecode of Vitest's own modules and of your externalized dependencies is written to disk and reused by later runs instead of being recompiled. Vitest propagates the variable to every worker, and workers persist the modules they compiled when they shut down. 97 + 98 + ```shell 99 + NODE_COMPILE_CACHE=node_modules/.cache/node-compile-cache vitest 100 + ``` 101 + 102 + The first run with an empty directory pays for serializing the compiled modules, so this is only worth enabling when the directory survives between runs: local runs, or CI pipelines that cache the directory. `NODE_DISABLE_COMPILE_CACHE=1` disables the cache entirely, taking precedence over `NODE_COMPILE_CACHE`. 103 + 104 + Note that Vitest automatically disables the compile cache in workers when the `v8` coverage provider is enabled — V8 serializes cached scripts without the source positions that precise coverage relies on. 105 + 94 106 ## Pool 95 107 96 108 By default Vitest runs tests in `pool: 'forks'`. While `'forks'` pool is better for compatibility issues ([hanging process](/guide/common-errors.html#failed-to-terminate-worker) and [segfaults](/guide/common-errors.html#segfaults-and-native-code-errors)), it may be slightly slower than `pool: 'threads'` in larger projects.
+21
packages/vitest/src/runtime/workers/init.ts
··· 2 2 import type { MetaEnv, WorkerSetupContext } from '../../types/worker' 3 3 import type { FileSpecification } from '../runner/types' 4 4 import type { VitestWorker } from './types' 5 + // default import: `flushCompileCache` only exists since Node 22.10, a named 6 + // import would fail to link on older versions 7 + import Module from 'node:module' 5 8 import { serializeError } from '@vitest/utils/error' 6 9 import { disableDefaultColors } from 'tinyrainbow' 7 10 import { Traces } from '../../utils/traces' ··· 241 244 case 'stop': { 242 245 await runPromise 243 246 247 + // Persist this worker's compile cache before the parent tears the 248 + // worker down — forks are SIGTERM'd and never reach Node's exit-time 249 + // flush, so without this the cache stays write-only for them. Runs 250 + // even when teardown throws (the compiled modules are still worth 251 + // persisting). A no-op when the cache is disabled or was fully loaded 252 + // from disk, and cheap (~tens of ms) otherwise, so every worker can 253 + // afford it. 254 + const persistCompileCache = () => { 255 + try { 256 + Module.flushCompileCache?.() 257 + } 258 + catch {} 259 + } 260 + 244 261 try { 245 262 const context = traces.getContextFromCarrier(message.otelCarrier) 246 263 ··· 256 273 257 274 await traces.finish() 258 275 276 + persistCompileCache() 277 + 259 278 send({ type: 'stopped', error, __vitest_worker_response__ }) 260 279 } 261 280 catch (error) { 281 + persistCompileCache() 282 + 262 283 send({ type: 'stopped', error: serializeError(error), __vitest_worker_response__ }) 263 284 } 264 285
+1 -18
packages/vitest/vitest.mjs
··· 1 1 #!/usr/bin/env node 2 - import * as module from 'node:module' 3 - 4 - // Enable Node's on-disk compile cache before importing the CLI so both the CLI 5 - // graph and (via the inherited env variable) every spawned worker skip V8 6 - // recompilation of unchanged modules. `enableCompileCache()` only affects the 7 - // current process — child processes pick the cache up from NODE_COMPILE_CACHE. 8 - // Respects an explicit NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE; the 9 - // API is not available before Node 22.8. 10 - try { 11 - const result = module.enableCompileCache?.() 12 - if (result?.directory && !process.env.NODE_COMPILE_CACHE) { 13 - process.env.NODE_COMPILE_CACHE = result.directory 14 - } 15 - } 16 - catch {} 17 - 18 - // eslint-disable-next-line antfu/no-top-level-await -- the import must not be hoisted above `enableCompileCache` 19 - await import('./dist/cli.js') 2 + import './dist/cli.js'
+3
pnpm-lock.yaml
··· 1337 1337 '@vitest/browser-preview': 1338 1338 specifier: workspace:* 1339 1339 version: link:../../packages/browser-preview 1340 + '@vitest/coverage-v8': 1341 + specifier: workspace:* 1342 + version: link:../../packages/coverage-v8 1340 1343 '@vitest/mocker': 1341 1344 specifier: workspace:* 1342 1345 version: link:../../packages/mocker
+16
test/e2e/fixtures/compile-cache/basic.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('worker sees the expected compile cache environment', () => { 4 + // set when the spec expects the v8 coverage provider to strip the cache 5 + if (process.env.EXPECT_COVERAGE_STRIPPED) { 6 + expect(process.env.NODE_COMPILE_CACHE).toBeUndefined() 7 + expect(process.env.NODE_DISABLE_COMPILE_CACHE).toBe('1') 8 + } 9 + // gate on the discriminator between the spec's runs, not on 10 + // EXPECTED_COMPILE_CACHE_DIR itself — if the spec's env stops propagating, 11 + // this fails loudly (toBe(undefined)) instead of passing vacuously 12 + else if (!process.env.NODE_DISABLE_COMPILE_CACHE) { 13 + expect(process.env.NODE_COMPILE_CACHE).toBe(process.env.EXPECTED_COMPILE_CACHE_DIR) 14 + } 15 + expect(document).toBeDefined() 16 + })
+9
test/e2e/fixtures/compile-cache/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + watch: false, 6 + environment: 'jsdom', 7 + pool: 'forks', 8 + }, 9 + })
+1
test/e2e/package.json
··· 24 24 "@vitejs/test-dep-virtual": "file:./deps/test-dep-virtual", 25 25 "@vitest/browser-playwright": "workspace:*", 26 26 "@vitest/browser-preview": "workspace:*", 27 + "@vitest/coverage-v8": "workspace:*", 27 28 "@vitest/mocker": "workspace:*", 28 29 "@vitest/test-dep-optimizer-external": "file:./deps/optimizer/external", 29 30 "@vitest/test-dep-optimizer-optimized": "file:./deps/optimizer/optimized",
+76
test/e2e/test/compile-cache.test.ts
··· 1 + import { existsSync, readdirSync, rmSync, statSync } from 'node:fs' 2 + import { join, resolve } from 'node:path' 3 + import { expect, test } from 'vitest' 4 + import { runVitestCli } from '../../test-utils' 5 + 6 + const fixture = resolve(import.meta.dirname, '../fixtures/compile-cache') 7 + const cacheDir = resolve(fixture, 'node_modules/.cache/compile-cache-test') 8 + 9 + function cacheEntries(): string[] { 10 + return readdirSync(cacheDir, { recursive: true, encoding: 'utf-8' }) 11 + .filter(file => statSync(join(cacheDir, file)).isFile()) 12 + } 13 + 14 + test('NODE_COMPILE_CACHE redirects the compile cache and the worker persists its graph', async () => { 15 + rmSync(cacheDir, { recursive: true, force: true }) 16 + 17 + // the fixture test asserts that the worker sees this exact NODE_COMPILE_CACHE 18 + const { exitCode } = await runVitestCli( 19 + { nodeOptions: { env: { 20 + NODE_COMPILE_CACHE: cacheDir, 21 + EXPECTED_COMPILE_CACHE_DIR: cacheDir, 22 + } } }, 23 + 'run', 24 + '--root', 25 + fixture, 26 + ) 27 + 28 + expect(exitCode).toBe(0) 29 + 30 + // jsdom is only loaded inside the worker, so its modules can reach the 31 + // cache only through the worker flush — the CLI graph alone is ~150 entries, 32 + // the worker graph pushes it past 1000 33 + expect(cacheEntries().length).toBeGreaterThan(300) 34 + }) 35 + 36 + test('NODE_DISABLE_COMPILE_CACHE wins over NODE_COMPILE_CACHE', async () => { 37 + rmSync(cacheDir, { recursive: true, force: true }) 38 + 39 + const { exitCode } = await runVitestCli( 40 + { nodeOptions: { env: { 41 + NODE_COMPILE_CACHE: cacheDir, 42 + NODE_DISABLE_COMPILE_CACHE: '1', 43 + } } }, 44 + 'run', 45 + '--root', 46 + fixture, 47 + ) 48 + 49 + expect(exitCode).toBe(0) 50 + expect(existsSync(cacheDir)).toBe(false) 51 + }) 52 + 53 + test('the cache is stripped from workers when v8 coverage is enabled', async () => { 54 + rmSync(cacheDir, { recursive: true, force: true }) 55 + 56 + const { exitCode } = await runVitestCli( 57 + { nodeOptions: { env: { 58 + NODE_COMPILE_CACHE: cacheDir, 59 + EXPECT_COVERAGE_STRIPPED: '1', 60 + } } }, 61 + 'run', 62 + '--root', 63 + fixture, 64 + '--coverage.enabled', 65 + '--coverage.provider=v8', 66 + '--coverage.reporter=text', 67 + ) 68 + 69 + expect(exitCode).toBe(0) 70 + 71 + // the CLI process itself still persists its own graph (Node enabled its 72 + // cache from the env var at startup, before coverage is known), but the 73 + // worker's jsdom graph must not reach the cache 74 + const entries = existsSync(cacheDir) ? cacheEntries() : [] 75 + expect(entries.length).toBeLessThan(300) 76 + })