Skip to content

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

Merged
sheremet-va merged 4 commits into
mainfrom
perf/persist-worker-compile-cache
Jul 9, 2026
Merged

sheremet-va merged 4 commits into
mainfrom
perf/persist-worker-compile-cache

Conversation

@sheremet-va

@sheremet-va sheremet-va commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

#10708 enabled Node's compile cache unconditionally. That turned out to be a bad default in two ways:

  • On cold environments — CI that doesn't persist the cache directory — it makes runs slower: workers pay the code-cache production cost, and thread workers additionally serialize their whole compiled graph when they are terminated, with zero payoff since the directory is thrown away.
  • For the default forks pool the cache was write-only anyway: SIGTERM'd workers never reach Node's exit-time flush, so worker entries (runtime, test environment, externalized deps) never reached the disk even when the directory did persist.

This PR makes the cache opt-in and makes opting in actually work:

  • The executable no longer calls module.enableCompileCache(). Setting NODE_COMPILE_CACHE=<dir> enables the cache for the CLI (Node reads it at startup) and Vitest propagates it to every worker.
  • Workers persist their compiled modules during graceful teardown (module.flushCompileCache() in the stop handler, both teardown paths; default import because a named import fails to link before Node 22.10). This is what makes the cache useful for forks.
  • The v8 coverage provider still strips the cache from workers (V8 serializes cached scripts without the source positions precise coverage relies on) — now covered by a test.
  • Documented in docs/guide/improving-performance.md as a tuning knob for environments where the directory survives between runs.

40-file happy-dom isolate: true suite, median of 5 interleaved runs (Apple M4 ×10, Node 24):

pool scenario main (always on) this PR
forks cold, cache not persisted (CI) 2123 ms 1924 ms (−9%)
forks NODE_COMPILE_CACHE, first run — 2366 ms
forks NODE_COMPILE_CACHE, subsequent runs — 1812 ms
threads cold, cache not persisted (CI) 2115 ms 1709 ms (−19%)
threads NODE_COMPILE_CACHE, first run — 2123 ms
threads NODE_COMPILE_CACHE, subsequent runs — 1618 ms

Cold runs get faster by default. Opting in pays the first-run serialization once and runs ~23% faster than that first run afterwards; the benefit over not enabling the cache at all grows with the size of the natively-imported graph (heavier environments and dependencies).

The e2e test covers env propagation into workers, NODE_DISABLE_COMPILE_CACHE precedence, and the coverage exclusion.

@netlify

netlify Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vitest-dev ready!

Name Link
🔨 Latest commit 143e4e2
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a4e66a640836c0008ab0b03
😎 Deploy Preview https://deploy-preview-10742--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@sheremet-va sheremet-va changed the title perf: persist the worker compile cache during graceful teardown perf: make the Node compile cache opt-in, persist worker caches on teardown Jul 8, 2026
@sheremet-va

Copy link
Copy Markdown
Member Author

@AriPerkkio I should've tested more without trusting AI, you were right to question what are the actual numbers 😄 I don't think the cold start cost justifies the warm improvement here since CI will always run cold

// persisting). A no-op when the cache is disabled or was fully loaded
// from disk, and cheap (~tens of ms) otherwise, so every worker can
// afford it.
const persistCompileCache = () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we kill the child, forks never actually flush the cache otherwise

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's actually small time window where the worker can gracefully exit, before main thread kills it. But anyways, flushing the cache manually like this is good approach.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

threads workers do flush gracefully, but we for forks we kill them and they don't have enough time (or even knowledge?) to flush. in my tests they never flushed the cache

@AriPerkkio AriPerkkio Jul 12, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing these two lines shows that forks worker does exit gracefully without the need of main thread killing it:

const sigkillTimeout = setTimeout(
() => fork.kill('SIGKILL'),
SIGKILL_TIMEOUT,
)

cd test/unit
pnpm run test test/math.test.ts --project forks --run

Maybe enabling compile cache prevents worker from exiting. 🤔

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense!

@sheremet-va
sheremet-va merged commit 941bc83 into main Jul 9, 2026
24 of 27 checks passed
@sheremet-va
sheremet-va deleted the perf/persist-worker-compile-cache branch July 9, 2026 06:21
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Sponsor
SponsoredKunjungi sekarang
Promo