Title: vm contexts never get freed after Jest runs a big test file (Node 24+, retained maps) · Issue #66490 · nodejs/node · GitHub
Open Graph Title: vm contexts never get freed after Jest runs a big test file (Node 24+, retained maps) · Issue #66490 · nodejs/node
X Title: vm contexts never get freed after Jest runs a big test file (Node 24+, retained maps) · Issue #66490 · nodejs/node
Description: Version v24.21.0 (also v24.8.0, v24.15.0, v26.10.0; not v22.14.0 or v22.18.0) Platform Darwin
Open Graph Description: Version v24.21.0 (also v24.8.0, v24.15.0, v26.10.0; not v22.14.0 or v22.18.0) Platform Darwin
X Description: Version v24.21.0 (also v24.8.0, v24.15.0, v26.10.0; not v22.14.0 or v22.18.0) Platform Darwin <host> 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:17:26 PDT 2026; root:xnu-12377.161.14~5...
Opengraph URL: https://github.com/nodejs/node/issues/66490
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"vm contexts never get freed after Jest runs a big test file (Node 24+, retained maps)","articleBody":"### Version\n\nv24.21.0 (also v24.8.0, v24.15.0, v26.10.0; not v22.14.0 or v22.18.0)\n\n### Platform\n\n```text\nDarwin \u003chost> 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:17:26 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6041 arm64\n```\n\n### Subsystem\n\nvm\n\n### What steps will reproduce the bug?\n\nI put a small repro here: https://github.com/markmssd/nodejs-repro/tree/vm-context-retained-maps\n\n```sh\ngit clone -b vm-context-retained-maps https://github.com/markmssd/nodejs-repro.git\ncd nodejs-repro\nnpm install\nnpm run repro # \"still in memory\" goes up to 8\nnpm run repro:fixed # same thing with --retain-maps-for-n-gc=0, stays at 1\n```\n\nJest runs each test file in its own `vm` context. `many.test.js` has 3000 trivial tests, which is enough to get Jest's per-test code optimized, and then there are seven files with a single test each. `counting-environment.js` is Jest's node environment plus a small counter: after each test file it runs a GC and prints how many test-file globals are still around.\n\n### How often does it reproduce? Is there a required condition?\n\nEvery time on Node 24 and 26. You just need one test file that does enough per-test work for Jest's code to get optimized. 3000 empty tests does it. In our real project, about 120 tests is enough, because our setup files register around 12 beforeEach hooks.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nOnce a test file is done, its context should be garbage collected, so only the file that's currently running should still be in memory:\n\n```\ntest files finished: 1, still in memory: 1\ntest files finished: 2, still in memory: 1\n...\ntest files finished: 8, still in memory: 1\n```\n\nThat's what I get on Node 22, and on Node 24/26 with `--retain-maps-for-n-gc=0` (`npm run repro:fixed`).\n\n### What do you see instead?\n\nOn Node 24 and 26, none of the test files after `many.test.js` ever get freed:\n\n```\ntest files finished: 1, still in memory: 1\ntest files finished: 2, still in memory: 2\n...\ntest files finished: 8, still in memory: 8\n```\n\nThis is how I found it: in our real test suite, one Jest worker running 180 test files ended up holding 44 old contexts and about 1.1 GB of heap. With `--retain-maps-for-n-gc=0` it's 2 contexts and around 330 MB. In CI our workers peak at 1.4–1.9 GB of retained heap, and 670–820 MB with the flag.\n\n### Additional information\n\n- The old contexts aren't strongly reachable. A heap snapshot (`v8.writeHeapSnapshot()`, whose GC tries to reduce memory) frees them, and afterwards the snapshot only shows the current one. Plain `global.gc()` calls never do, which is why I think it's the retained maps. `--retain-maps-for-n-gc=0` makes it go away completely.\n- `--no-maglev` mostly hides it (2 stay alive instead of all of them), I assume because less code gets optimized.\n- I did find one strong path, but it only keeps a single previous test file alive: from Jest's runtime, through the `FeedbackVector` / `AllocationSite` of `createInitialModule`, the boilerplate object's `Map` → `DescriptorArray` → `AccessorPair` (a `module.parent` getter Jest defines on every module with `Object.defineProperty`), and from that getter's closure to the test file's environment.\n- I couldn't reproduce it with a plain `node:vm` loop (new context each iteration with a hot function inside), so it probably depends on how Jest's code is shared between the outer realm and the contexts.","author":{"url":"https://github.com/markmssd","@type":"Person","name":"markmssd"},"datePublished":"2026-10-03T23:32:26Z","url":"https://github.com/nodejs/node/issues/66490"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:1202875e-da74-8f0c-6a4f-eef566b5d7fb |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | EBCA:142296:6E8224:8FC7A3:6AED305B |
| html-safe-nonce | 87a24d3bf892d7852e0f88e5b94881e51b70a973b15def24a07d7f8ff94ef6fe |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJFQkNBOjE0MjI5Njo2RTgyMjQ6OEZDN0EzOjZBRUQzMDVCIiwidmlzaXRvcl9pZCI6IjI2MTk5NjM0MTU2NTk4ODQ2MzUiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 0a4db0c5c3875ac9ae1da9303fe530a07285543dec5cdc684fe7e73dbecb7c08 |
| hovercard-subject-tag | issue:5694586843 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/66490/issue_layout |
| twitter:image | https://opengraph.githubassets.com/485266cc5a8064e889f2aae03cf019df2aaa34a8a087a2f8fc45ec5dd37d6086/nodejs/node/issues/66490 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/485266cc5a8064e889f2aae03cf019df2aaa34a8a087a2f8fc45ec5dd37d6086/nodejs/node/issues/66490 |
| og:image:alt | Version v24.21.0 (also v24.8.0, v24.15.0, v26.10.0; not v22.14.0 or v22.18.0) Platform Darwin |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | markmssd |
| hostname | github.com |
| expected-hostname | github.com |
| None | e51d0b7b5ab34b1688c05b54c3ed17b8658b2fa9bca973a9c740683ccb12cf4a |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 59ebb7b901cc154ecc179a44f266fcf69f343b30 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width