Writing Memory Leak Tests with Vitest and --expose-gc

You fixed a leak in a store, an event bus or a cache, and you want a fast unit test that fails if it ever comes back — without spinning up a browser. This guide from Automated Memory Leak Detection in CI, in Browser DevTools & Performance Profiling Workflows, shows two reliable patterns for leak tests in Vitest (the same approach works in Jest or the Node.js test runner): asserting that a specific object becomes collectable, and asserting that heap usage stays bounded across many iterations.

Symptom Root Cause Immediate Action Measurable Impact
Leak fixes regress silently No test covers retention Add a WeakRef collectability test for the object that leaked Regression fails in milliseconds
global.gc is not a function Node started without --expose-gc Pass --expose-gc to the test worker via execArgv GC callable inside tests
WeakRef test fails even though code is fixed Target kept alive until the end of the current job await a tick before calling gc() and checking deref() Deterministic pass on fixed code
Heap-growth test is flaky Single GC and small N; noise dominates Warm up, run many iterations, GC several times, use a threshold Stable results across runs
Test passes locally, fails in CI Different Node version or parallel test interference Pin Node, run leak tests in an isolated fork Consistent behaviour everywhere

Root Cause: Leaks Are About Reachability, So Test Reachability

A memory leak is an object that stays reachable after the program is done with it. Measuring total heap size in a unit test is a noisy proxy for that: V8 allocates lazily, collects when it chooses, and keeps compiled code and inline caches that grow as tests warm up. A more precise test asks the direct question: after teardown, can this object be collected?

JavaScript gives you exactly the tool for that. A WeakRef holds an object without keeping it alive; after all strong references are gone and a garbage collection runs, ref.deref() returns undefined. Node.js exposes a synchronous global.gc() when started with --expose-gc, so a test can create the object, register it with whatever you are testing, run the teardown, drop its own reference, force collection, and assert deref() is undefined. If the code under test still holds the object — in a listener list, a cache, a closure — the assertion fails and the test names the leaking object.

One subtlety trips almost everyone: the specification requires that an object targeted by a WeakRef created or dereferenced during the current job stays alive until that job finishes (the “KeepDuringJob” rule, described in WeakRef deref and object lifetime guarantees). If you call gc() in the same synchronous block, the target survives and the test fails on correct code. Awaiting a macrotask (for example await new Promise((r) => setTimeout(r, 0))) before collecting ends the job and makes the test deterministic.

For leaks that are about accumulation rather than one object — a cache that should stay bounded — a heap-growth test is appropriate: run the operation thousands of times, collect, and assert that process.memoryUsage().heapUsed grew by less than a threshold. Warm-up runs and repeated collection make it stable. Both patterns complement the browser-level checks in finding leaks with Memlab scenarios.

The WeakRef collectability test Seven steps left to right in two rows: create the object; register it with the code under test; call teardown; drop the test's own strong reference; await a macrotask so the KeepDuringJob rule no longer applies; call global gc; assert that ref.deref returns undefined. If the code under test still holds the object, the final assertion fails. 1. create obj ref = new WeakRef(obj) 2. register bus.on(…, obj.handler) 3. teardown obj.dispose() 4. drop ref obj = null 5. await a macrotask ends KeepDuringJob 6. global.gc() needs --expose-gc 7. expect deref() toBeUndefined()

Step-by-Step Fix

  1. Expose GC to the test workers. Configure Vitest to run the leak tests in forked workers with --expose-gc (see vitest.config.js below). Verification: a test containing expect(typeof globalThis.gc).toBe('function') passes.
  2. Write a collectability test for the object that leaked. Create the object, register it with the system under test, run teardown, drop your reference, await a macrotask, call gc(), and assert ref.deref() is undefined. Verification: the test fails on the leaky version of the code (check out the old commit or comment out the fix).
  3. Write a bounded-growth test for accumulating structures. Warm up with a few hundred iterations, collect, record heapUsed, run several thousand iterations, collect again, and assert the difference is under a threshold. Verification: growth is near zero on fixed code and clearly above the threshold on leaky code.
  4. Stabilise. Call gc() two or three times with awaits in between, keep iteration counts large relative to noise, and avoid console output inside loops. Verification: running the test 20 times in a row gives the same result every time.
  5. Isolate the leak tests. Put them in their own project or file pattern so they run in a dedicated fork without other tests allocating in parallel. Verification: the leak project runs alone with vitest run --project leaks.
  6. Run them in CI on every change. Add the command to your pipeline. Verification: a pull request that reintroduces the leak fails this job.
Bounded-growth test results After a warm-up and five thousand subscribe and unsubscribe iterations, heapUsed grows by 14.6 megabytes on the leaky code, far above the one megabyte threshold, and by 0.08 megabytes on the fixed code, well below it. heapUsed growth after 5,000 iterations (post-GC) Leaky build 14.6 MB — fails Fixed build 0.08 MB — passes threshold 1 MB

Command and Code Reference

Use case: run leak tests in forks with GC exposed. A separate project keeps these tests isolated from the rest of the suite.

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: [
      { test: { name: 'unit', include: ['src/**/*.test.js'], exclude: ['src/**/*.leak.test.js'] } },
      {
        test: {
          name: 'leaks',
          include: ['src/**/*.leak.test.js'],
          pool: 'forks',                                   // real child processes
          poolOptions: { forks: { execArgv: ['--expose-gc'], singleFork: true } },
        },
      },
    ],
  },
});

Use case: helpers that make both test patterns deterministic.

// test/gc-helpers.js
const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); // ends the current job

export async function collect(times = 3) {
  for (let i = 0; i < times; i++) {
    await tick();          // release WeakRef KeepDuringJob holds
    globalThis.gc();       // full, synchronous collection
  }
}

export async function heapAfterGc() {
  await collect();
  return process.memoryUsage().heapUsed;
}

Use case: the two test patterns against an event bus that used to leak handlers.

// src/bus.leak.test.js
import { describe, it, expect } from 'vitest';
import { createBus } from './bus.js';
import { collect, heapAfterGc } from '../test/gc-helpers.js';

describe('event bus retention', () => {
  it('releases a subscriber after unsubscribe', async () => {
    const bus = createBus();
    let subscriber = { onEvent() {}, payload: new Array(10_000).fill('x') };
    const ref = new WeakRef(subscriber);

    const off = bus.on('change', subscriber.onEvent.bind(subscriber));
    off();                         // teardown under test
    subscriber = null;             // drop the test's own strong reference

    await collect();
    expect(ref.deref()).toBeUndefined(); // still defined ⇒ bus kept the handler
  });

  it('keeps heap bounded across many subscribe/unsubscribe cycles', async () => {
    const bus = createBus();
    const cycle = () => { const off = bus.on('change', () => {}); off(); };
    for (let i = 0; i < 500; i++) cycle();       // warm-up: JIT, inline caches
    const before = await heapAfterGc();
    for (let i = 0; i < 5_000; i++) cycle();
    const growth = (await heapAfterGc()) - before;
    expect(growth).toBeLessThan(1 * 1024 * 1024); // 1 MB threshold
  });
});

Verification and Regression Prevention

Prove each test is meaningful by making it fail: temporarily revert the fix (or add bus.handlers.push(handler) somewhere) and confirm the collectability test reports a defined deref() and the growth test exceeds its threshold. A leak test that has never failed may not be testing anything. Then run each test repeatedly — vitest run --project leaks --repeat 20 or a shell loop — to confirm there is no flakiness before adding it to CI.

Keep the threshold honest. Set it just above the noise you observe on fixed code — typically well under 1 MB for thousands of iterations of a small operation — rather than generously high, and never raise it to make a failing test pass without understanding why growth increased. For browser code paths that depend on the DOM, combine these Node tests with browser-level checks such as Playwright memory testing for single-page apps.

Proving a leak test can fail Temporarily revert the fix and confirm the collectability test reports a defined deref and the growth test exceeds its threshold. Restore the fix, run the test twenty times to rule out flakiness, and only then add it to CI. Revert the fix or push a handler into the bus Test must fail deref() defined, growth over threshold Restore the fix test passes again Repeat 20× vitest run --repeat 20 Add to CI threshold just above noise

Edge Cases and Gotchas

Closures in the test itself

If the test keeps a closure that references the object — an expect callback, a spy’s recorded arguments, a mock’s call history — the object stays reachable and the test fails for the wrong reason. Clear mocks with vi.clearAllMocks() before collecting, and avoid capturing the object in helper closures.

Console logging retains objects

Logging an object with console.log can keep a reference in some environments and test reporters. Remove logging from leak tests or log only primitive values.

Coverage instrumentation changes results

Coverage tools wrap functions and keep counters and sometimes extra references. Run leak tests without coverage, or accept that the thresholds must account for the instrumentation.

Worker isolation and module caches

With singleFork or isolate: false, modules loaded by earlier tests persist and may hold objects from previous tests. Leak tests should create fresh instances of the system under test and not rely on module-level singletons that other tests touched.

Frequently Asked Questions

Is it safe to use --expose-gc in tests?

Yes. It only exposes a function that triggers collection; it does not change how the collector works. Keep it out of production processes, where manual collection causes unnecessary pauses, but use it freely in test workers.

Why does my WeakRef test fail on code I know is fixed?

Most often because gc() runs in the same synchronous job in which the WeakRef was created or dereferenced, so the specification requires the target to stay alive. Await a macrotask before collecting, and make sure no mocks, spies or closures in the test still reference the object.

Can I test browser code this way?

For logic that does not depend on real DOM behaviour, yes — run it in Node with jsdom or happy-dom. For DOM-specific leaks such as detached nodes, listeners on real elements or framework unmounting, use a real browser through Puppeteer, Playwright or Memlab.