Sitelet https://github.com/nodejs/node/issues/63186
Skip to content

vm: SourceTextModule memory leak - instances retained via async context frames after evaluate() #63186

Description

@SimenB

Version

v26.1.0

Platform

Darwin Mac.lan 25.4.0 Darwin Kernel Version 25.4.0: Thu Mar 19 19:31:17 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T6020 arm64

Subsystem

vm

What steps will reproduce the bug?

Run the following script with node --experimental-vm-modules --expose-gc node-vm-leak.mjs

// node-vm-leak.mjs

import { writeHeapSnapshot } from "node:v8";
import vm from "node:vm";

const ITERATIONS = 10;
// ~80 MB array
const SOURCE = `export const big = new Array(10_000_000).fill(0);`;

async function runIteration(withDynamicImport) {
  const context = vm.createContext({});
  const mod = new vm.SourceTextModule(SOURCE, {
    context,
    ...(withDynamicImport
      ? {
          importModuleDynamically: () => {
            throw new Error("no dynamic imports");
          },
        }
      : {}),
  });
  await mod.link(() => {
    throw new Error("no imports");
  });
  await mod.evaluate();
  // Drop all JS-side references and force GC.
  // (context and mod go out of scope here)
}

console.log("variant: without importModuleDynamically");
for (let i = 0; i < ITERATIONS; i++) {
  await runIteration(false);
  global.gc();
  const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1);
  console.log(`  iteration ${i + 1}: ${mb} MB heap`);
}

// Write snapshot to disk — avoids the DevTools pre-snapshot GC.
const snapshotPath = writeHeapSnapshot();
console.log(`\n*** Heap snapshot written to: ${snapshotPath} ***`);

console.log("\nvariant: with importModuleDynamically closure");
for (let i = 0; i < ITERATIONS; i++) {
  await runIteration(true);
  global.gc();
  const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1);
  console.log(`  iteration ${i + 1}: ${mb} MB heap`);
}

This 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 context and mod go out of scope, gc() is called, and there are no other JS-side references to either object, both the vm.Context and the vm.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:

iteration 1:  79.6 MB heap
iteration 2: 156.0 MB heap
iteration 3: 232.5 MB heap
iteration 4: 308.9 MB heap
iteration 5: 385.4 MB heap
iteration 6: 461.8 MB heap
iteration 7: 538.3 MB heap
iteration 8: 614.7 MB heap
iteration 9: 691.3 MB heap
iteration 10: 767.7 MB heap

A heap snapshot written via v8.writeHeapSnapshot() (to avoid the pre-snapshot GC that DevTools triggers) was loaded into DevTools and the .heapsnapshot file 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:

SourceTextModule @N
  ← <symbol> in Error @N           (stack trace stored under a symbol)
    ← [0] in Array @N
      ← args in {<symbol kAsyncContextFrame>, callback, args} @N
        ← [1] in Array @N
          ← list in FixedCircularBuffer @N
            ← head in FixedQueue @N
              ← queue in system / Context @N   (the vm context)
                ← context in queueMicrotask() @N
                  ← queueMicrotask in global @N   ← GC root

The GC root is the main process global. Its queueMicrotask function closes over a reference to the vm.Context. That context's microtask FixedQueue contains an AsyncContextFrame whose args holds an Error. The Error's stack trace (stored under a symbol) is an array of 7 CallSiteInfo entries. Only one of them — the module evaluation frame — retains significant memory: its slot 1 (the function slot) holds the SourceTextModule directly. In V8's CallSiteInfo, slot 1 is the function of that frame; for a module's top-level evaluation, the "function" is the module itself.

The FixedCircularBuffer backing the queue has exactly 2 populated slots: index 0 (a tiny AsyncContextFrame retaining almost nothing) and index 1 (the frame described above, retaining ~80 MB). The remaining ~2046 slots are undefined. So each evaluate() call leaves exactly 2 unprocessed frames in the vm context's microtask queue — one of which pins the SourceTextModule.

After mod.evaluate() resolves and all JS-side variables go out of scope, the vm context and SourceTextModule remain live because:

  1. the vm context is captured inside the main global's queueMicrotask closure, and
  2. the vm context's microtask queue still holds an async context frame — created during module evaluation for async stack trace tracking — whose Error stack contains a CallSiteInfo with the SourceTextModule in its function slot.
Image Image

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 inside ModuleWrap @23561, which is in turn referenced via <symbol kWrap> on the JS SourceTextModule @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 queueMicrotask retainer paths. The vm context's own queueMicrotask (visible as queueMicrotask in system / Context @36643 and queueMicrotask in {setupTaskQueue, queueMicrotask} @41565) is also a retainer, in addition to the main global's closure. Both paths lead back to the same FixedQueue on 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.compileFunction leaks 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 through importModuleDynamically bookkeeping. The issue reproduces identically with or without an importModuleDynamically callback.

Real-world impact. In Jest's ESM mode (--experimental-vm-modules), each test file runs inside its own vm.Context and creates multiple vm.SourceTextModule instances. 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.

Activity

  1. 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
  2. added
    vmIssues and PRs related to the vm subsystem.
    loadersIssues and PRs related to ES module loaders.
    on May 12, 2026
  3. joyeecheung commented on May 12, 2026

    @joyeecheung
    Member

    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() as gc(), 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 heap
    
  4. joyeecheung commented on May 12, 2026

    @joyeecheung
    Member

    FWIW, 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 because gc() 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.

  5. joyeecheung commented on May 13, 2026

    @joyeecheung
    Member

    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).

  6. github-actions commented on Aug 12, 2026

    @github-actions
    Contributor

    This 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.

  7. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Aug 12, 2026
  8. SimenB commented on Aug 14, 2026

    @SimenB
    MemberAuthor

    not stale

  9. removed
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Aug 15, 2026
  10. joyeecheung commented on Aug 25, 2026

    @joyeecheung
    Member

    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:

    1. 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.
    2. 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.

  11. joyeecheung commented on Aug 26, 2026

    @joyeecheung
    Member

    Tenative upstream CL for option 2 mentioned above: https://chromium-review.googlesource.com/c/v8/v8/+/8289930

  12. SimenB commented on Aug 26, 2026

    @SimenB
    MemberAuthor

    Exciting!

  13. joyeecheung commented on Sep 23, 2026

    @joyeecheung
    Member

    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. setImmediate to 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`);
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    loadersIssues and PRs related to ES module loaders.vmIssues and PRs related to the vm subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions