Repository navigation
vm: SourceTextModule memory leak - instances retained via async context frames after evaluate() #63186
Description
Activity
- changed the title
[-]`vm.SourceTextModule` is never garbage collected[/-][+]vm: `SourceTextModule` memory leak - instances retained via async context frames after `evaluate()`[/+]on May 8, 2026 - addedvmIssues and PRs related to the vm subsystem.Issues and PRs related to the vm subsystem.loadersIssues and PRs related to ES module loaders.Issues and PRs related to ES module loaders.
on May 12, 2026 It doesn't appear to me that it's a leak - in the repro, after taking the heap snapshot, the memory usage can fall back to ~80MB (i.e. if you abuse
writeHeapSnapshot()asgc(), the heap memory usage would actually roughly stay flat). When taking multiple heap snapshots between iterations, the memory increase is negligible between them and the SourceTextModule etc. won't appear in the diff of the heap snapshots, either. This indicates that V8's GC is actually capable of purging the unreachable objects when there's enough pressure (heap snapshot basically generates the highest level of pressure in V8). The symptom here is that the GC is too lazy, not that it fails to collect.import { writeHeapSnapshot } from "node:v8"; import vm from "node:vm"; import { setImmediate } from "node:timers/promises"; const ITERATIONS = 10; // ~80 MB array const SOURCE = `export const big = new Array(10_000_000).fill(0);`; async function runIteration() { const mod = new vm.SourceTextModule(SOURCE); mod.linkRequests([]); mod.instantiate(); await mod.evaluate(); } async function forceGC(useHeapSnapshot = false) { if (useHeapSnapshot) { writeHeapSnapshot('./dummy.heapsnapshot'); } else { gc({ type: 'major', execution: 'sync' }); } } console.log('use gc({ type: \'major\', execution: \'sync\' });') for (let i = 0; i < ITERATIONS; i++) { await runIteration(); await forceGC(false); const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); console.log(` iteration ${i + 1}: ${mb} MB heap`); } console.log('Abuse heap snapshot GC') for (let i = 0; i < ITERATIONS; i++) { await runIteration(); await forceGC(true); const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); console.log(` iteration ${i + 1}: ${mb} MB heap`); }
use gc({ type: 'major', execution: 'sync' }); iteration 1: 80.0 MB heap iteration 2: 156.3 MB heap iteration 3: 232.6 MB heap iteration 4: 308.9 MB heap iteration 5: 385.2 MB heap iteration 6: 461.5 MB heap iteration 7: 537.8 MB heap iteration 8: 614.1 MB heap iteration 9: 690.5 MB heap iteration 10: 767.0 MB heap Abuse heap snapshot GC iteration 1: 80.0 MB heap iteration 2: 80.0 MB heap iteration 3: 80.0 MB heap iteration 4: 80.0 MB heap iteration 5: 80.0 MB heap iteration 6: 80.0 MB heap iteration 7: 80.0 MB heap iteration 8: 80.0 MB heap iteration 9: 80.0 MB heap iteration 10: 80.0 MB heapFWIW, if you want a very eager GC, try
gc({ flavor: 'last-resort', execution: 'sync' });(this is testing only, there's no stability guarantee about this becausegc()is an internal function from V8 only intended for V8's own tests). In the example above, using this would also force the memory to stay flat.Oh wait, I think I jumped to the conclusion too quick - it's the compilation cache holding the module exports alive, and the last resort GC clears the compilation cache. I think to fully address we have to go back to https://issues.chromium.org/issues/41480316 (alternatively we can trigger a reset of the compilation cache when the process is really going out of memory, but that's more of a band-aid).
Reacted by Valentin Semirulnik and Simen Bekkhusgithub-actions commented
on Aug 12, 2026 on Aug 12, 2026 – with GitHub ActionsContributorMore actionsThis issue has been marked as stale due to 90 days of inactivity.
It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.- addedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Aug 12, 2026 not stale
- removedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Aug 15, 2026 Looking into this a bit deeper as part of the investigation of the new vm module API proposal, I have 2 rough ideas to solve it, without doing the big refactoring required to alter the host defined options management:
- Add an option for the user to explicitly opt out of in-isolate compilation cache when compiling a source text module. This means when they know that they are going to compile a module that'll keep changing, they should opt out of it, but when they compile something that'll stay constant (e.g. a test harness?) they can keep using it.
- Make the ESM bytecode ageable so that the cache can be cleared under pressure.
It's not clear to me yet why the upstream decided not to do 2, going to check with the V8 team. If that's not viable, updating the new vm Module proposal to include 1 may be the way to go - although even if 2 is viable in the upstream, 1 may still be a good idea for user to optimize memory usage wisely. But even 1 still needs buy-in from the upstream because it requires a new V8 API.
Reacted by Simen BekkhusTenative upstream CL for option 2 mentioned above: https://chromium-review.googlesource.com/c/v8/v8/+/8289930
Reacted by Simen Bekkhus, Valentin Semirulnik and Joe LencioniExciting!
FWIW, here's a better test I was using to test the V8 CL (do not use
gc()calls, forced GC actually keeps the compilation cache age unchanged per V8 policy. The right way to give it GC stimulus is through memory pressure, and use e.g.setImmediateto give GC some time to kick in). This breaks on main and passes with the CL I am working on.// node --max-old-space-size=800 --experimental-vm-modules --expose-gc test.js import vm from "node:vm"; import { setImmediate } from "node:timers/promises"; const ITERATIONS = 10; const SOURCE = `export const big = new Array(10_000_000).fill(0);`; async function runIteration() { const mod = new vm.SourceTextModule(SOURCE); mod.linkRequests([]); mod.instantiate(); await mod.evaluate(); } for (let i = 0; i < ITERATIONS; i++) { await runIteration(); await setImmediate(); const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); console.log(` iteration ${i + 1}: ${mb} MB heap`); } for (let i = 0; i < ITERATIONS; i++) { await runIteration(); await setImmediate(); const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); console.log(` iteration ${i + 1}: ${mb} MB heap`); }
Reacted by Simen Bekkhus and Eduardo Eidelwein Berlitz
Version
v26.1.0
Platform
Subsystem
vmWhat steps will reproduce the bug?
Run the following script with
node --experimental-vm-modules --expose-gc node-vm-leak.mjsThis reproduction is based on https://github.com/eberlitz/jest-leak, thanks to them for the great minimal reproduction in Jest that was easy to port to plain Node APIs 👍
How often does it reproduce? Is there a required condition?
100% of the time
What is the expected behavior? Why is that the expected behavior?
Heap should stay flat after the first iteration. Once
contextandmodgo out of scope,gc()is called, and there are no other JS-side references to either object, both thevm.Contextand thevm.SourceTextModule(and anything reachable from the module's namespace) should be eligible for collection.What do you see instead?
Heap grows ~80 MB per iteration and never shrinks:
A heap snapshot written via
v8.writeHeapSnapshot()(to avoid the pre-snapshot GC that DevTools triggers) was loaded into DevTools and the.heapsnapshotfile was shared with Claude Code for analysis.Everything below this point is Claude Code's interpretation of the snapshot. I have not independently verified it and it may contain errors. I'm including it as a starting point for investigation.
Claude Code identified the following retainer chain anchored at
SourceTextModule:The GC root is the main process
global. ItsqueueMicrotaskfunction closes over a reference to thevm.Context. That context's microtaskFixedQueuecontains anAsyncContextFramewhoseargsholds anError. TheError's stack trace (stored under a symbol) is an array of 7CallSiteInfoentries. Only one of them — the module evaluation frame — retains significant memory: its slot 1 (the function slot) holds theSourceTextModuledirectly. In V8'sCallSiteInfo, slot 1 is the function of that frame; for a module's top-level evaluation, the "function" is the module itself.The
FixedCircularBufferbacking the queue has exactly 2 populated slots: index 0 (a tinyAsyncContextFrameretaining almost nothing) and index 1 (the frame described above, retaining ~80 MB). The remaining ~2046 slots areundefined. So eachevaluate()call leaves exactly 2 unprocessed frames in the vm context's microtask queue — one of which pins theSourceTextModule.After
mod.evaluate()resolves and all JS-side variables go out of scope, the vm context and SourceTextModule remain live because:queueMicrotaskclosure, andErrorstack contains aCallSiteInfowith theSourceTextModulein its function slot.Additional information
This is also all Claude Code 😀 Hopefully it helps rather than adding noise 🙂
Full object cluster. A second snapshot view shows the internal V8
SourceTextModule(C++ side,system / SourceTextModule @23563) sitting insideModuleWrap @23561, which is in turn referenced via<symbol kWrap>on the JSSourceTextModule @63249. The JS wrapper, the C++ModuleWrap, and the internal V8 module are one cluster — all kept alive by the same microtask queue root. Fixing the JS-side retention fixes all three.Multiple
queueMicrotaskretainer paths. The vm context's ownqueueMicrotask(visible asqueueMicrotask in system / Context @36643andqueueMicrotask in {setupTaskQueue, queueMicrotask} @41565) is also a retainer, in addition to the main global's closure. Both paths lead back to the sameFixedQueueon the vm context.Relation to prior fixes. This leak was reported previously in #33439 (2020) and #41101 (2021) and was partially addressed by #48510 (released in v20.8.0) and #46785. Those fixes addressed
vm.Script/vm.compileFunctionleaks via symbol-based host-defined options. The current leak is distinct: it goes through async stack trace capture (AsyncContextFrame/CallSiteInfo) and the microtask queue infrastructure, not throughimportModuleDynamicallybookkeeping. The issue reproduces identically with or without animportModuleDynamicallycallback.Real-world impact. In Jest's ESM mode (
--experimental-vm-modules), each test file runs inside its ownvm.Contextand creates multiplevm.SourceTextModuleinstances. With--runInBand, heap grows ~80 MB per test file (matching the module namespace size) with no upper bound. A 22-file suite in the linked reproduction climbs from ~100 MB to ~1.4 GB. Jest tears down its registries and nulls the context reference after each test file; there is nothing user-land can do to work around the leak.Relation to the loader redesign. #62720 is working on a new vm/modules loader API. It's unclear whether that work touches this code path, but flagging it here in case there is overlap.