Forcing Garbage Collection with --expose-gc

You need a clean, garbage-free heap reading for a leak test, or you want to know whether memory drops after a collection before blaming a leak — and you have heard that calling gc() is both essential and forbidden. This guide from How Mark-and-Sweep Garbage Collection Works, part of JavaScript Memory Fundamentals & Runtime Mechanics, shows every way to trigger collection on demand, what each actually does, and where it belongs.

Symptom Root Cause Immediate Action Measurable Impact
gc is not defined GC function not exposed Start Node with --expose-gc (or Chrome with --js-flags=--expose-gc) globalThis.gc() available
Heap readings in tests vary by MB between runs Readings include uncollected garbage Call gc() (twice, with a tick between) before reading Variance drops to KB
WeakRef still defined after gc() KeepDuringJob keeps targets alive within the current job Await a macrotask before collecting Deterministic collectability tests
Production service calls gc() on a timer Misguided attempt to “free memory” Remove it; fix the leak or size the heap No self-inflicted pauses
Need to force GC in a browser page under automation No page API for GC Use CDP HeapProfiler.collectGarbage Clean readings in Puppeteer/Playwright

Root Cause: V8 Decides When to Collect — Unless You Ask

V8 runs garbage collection on its own schedule: scavenges when the young generation fills, major mark-sweep-compact cycles when the old generation approaches a dynamically computed limit, and extra collections in idle time or under memory pressure. That schedule is excellent for throughput and latency, but it makes memory measurement noisy, because at any moment the heap contains an unknown amount of garbage. Tests and diagnostics need a way to remove that garbage first.

In Node.js, starting the process with --expose-gc defines a global gc() function. Called with no arguments, it performs a full, synchronous, stop-the-world major collection (including the young generation). Recent V8 versions accept an options object — for example gc({ type: 'minor' }) for a scavenge only, or gc({ execution: 'async' }) to schedule a collection and return a promise — which is useful for testing specific behaviours. In Chrome, launching with --js-flags=--expose-gc exposes window.gc() in the same way; more commonly you use the Collect garbage button (trash-can icon) in the DevTools Memory or Performance panels, or the DevTools Protocol command HeapProfiler.collectGarbage from Puppeteer or Playwright. Taking a heap snapshot also forces a full collection first.

What gc() does not do is equally important. It does not free anything that is still reachable, so it can never fix a leak. It does not immediately run FinalizationRegistry callbacks — those are scheduled as separate tasks afterwards. It does not collect objects targeted by a WeakRef that was created or dereferenced in the current job, because the specification keeps those alive until the job ends, which is why tests must await a macrotask first (see WeakRef deref and object lifetime guarantees). And it does not necessarily return memory to the operating system: freed pages may be kept for reuse, so RSS can stay high after a collection.

In production, calling gc() is almost always harmful. It forces a full, blocking collection at a moment V8 did not choose, often with hundreds of milliseconds of pause on a large heap, and it disables the heuristics that make collection cheap. If memory grows, forcing collection only hides the symptom until the next spike; the leak or the undersized heap is still there.

Ways to trigger a collection on demand Node.js: start with --expose-gc and call gc(), used in tests and diagnostics. Chrome launched with --js-flags=--expose-gc: call window.gc() in the page, used in local experiments. DevTools Memory or Performance panel: the Collect garbage button, used during manual investigation. DevTools Protocol HeapProfiler.collectGarbage: used from Puppeteer and Playwright in automated tests. Production code: none of these should be used. Environment How Use it for Node.js node --expose-gc · gc() tests, diagnostics Chrome (flag) --js-flags=--expose-gc · window.gc() local experiments DevTools UI Memory / Performance → Collect garbage manual investigation Automation CDP HeapProfiler.collectGarbage Puppeteer / Playwright Production code none — do not force GC fix leaks or heap sizing

Step-by-Step Fix

  1. Expose GC only where you measure. Add --expose-gc to the test runner’s worker arguments or to a diagnostic script’s launch command, never to the production start command. Verification: typeof globalThis.gc === 'function' in tests; undefined in production.
  2. Collect before every measurement. Call gc(), await a macrotask, and call it again, then read process.memoryUsage().heapUsed. Verification: repeated readings on an idle process agree within a few KB.
  3. Check “leak or garbage?” during an investigation. In DevTools, click Collect garbage and watch the JS heap figure. Verification: if memory drops back to baseline, it was garbage; if it stays high, retained objects remain and a snapshot diff is needed.
  4. Force GC from automation. In Puppeteer or Playwright, send HeapProfiler.collectGarbage over a CDP session before reading JSHeapUsedSize. Verification: browser-side memory tests become stable, as in reducing noise in automated memory tests.
  5. Remove any production gc() calls. Search the codebase for gc( and --expose-gc in deployment configuration. Verification: no production path forces collection; memory behaviour is governed by V8 heuristics and your heap limits.
  6. Fix the underlying problem instead. If someone added forced GC to “keep memory down”, investigate the growth with snapshots and fix retention, or raise the heap limit appropriately. Verification: memory is stable without forced collections.
Forced GC in production hurts latency A service that calls gc() every ten seconds shows p99 latency of 340 milliseconds because each forced full collection pauses the main thread for around 250 milliseconds on a 1.2 gigabyte heap. Removing the forced collections brings p99 down to 48 milliseconds with the same memory footprint. Request latency p99 (ms), same traffic and heap gc() every 10 s 340 ms — each forced full GC pauses ~250 ms V8 schedules GC 48 ms

Command and Code Reference

Use case: stable heap readings in a Node.js diagnostic script.

// measure.mjs — node --expose-gc measure.mjs  (ES module: top-level await)
const tick = () => new Promise((r) => setTimeout(r, 0));

async function settledHeapMB() {
  for (let i = 0; i < 2; i++) {
    await tick();            // end the current job (WeakRef targets can now die)
    globalThis.gc();         // full, synchronous collection
  }
  return process.memoryUsage().heapUsed / 1048576;
}

const before = await settledHeapMB();
await runScenario();          // the code under investigation
const after = await settledHeapMB();
console.log(`retained by scenario: ${(after - before).toFixed(2)} MB`);

Use case: force GC in a browser page from Puppeteer.

// In a Puppeteer or Playwright (Chromium) test
const cdp = await page.createCDPSession();              // Playwright: context.newCDPSession(page)
await cdp.send('HeapProfiler.collectGarbage');          // full GC in the page's renderer
const { JSHeapUsedSize } = await page.metrics();        // now reflects live objects
console.log(`live JS heap: ${(JSHeapUsedSize / 1048576).toFixed(1)} MB`);

Use case: enable GC at runtime in a one-off debugging session. Useful when you cannot restart with flags; not for production code.

// Debug-only: expose gc() without restarting (Node.js)
const v8 = require('node:v8');
const vm = require('node:vm');
v8.setFlagsFromString('--expose-gc');
const gc = vm.runInNewContext('gc');   // fetch the function from a fresh context
gc();

Verification and Regression Prevention

Forced collection is being used correctly when it appears only in tests, benchmarks and diagnostic scripts, those measurements are stable across runs, and production processes run without --expose-gc. Add a startup assertion or deployment check that fails if the production command line contains --expose-gc, and a lint rule that forbids gc( outside test directories.

When an investigation shows that memory drops after a forced collection, record that as evidence of garbage, not a leak, and move on to latency or churn questions; when memory does not drop, move to heap snapshots and retainer analysis. That split keeps teams from chasing leaks that are really just V8’s normal lazy collection, and from ignoring leaks that forced collection cannot touch.

Where --expose-gc belongs Forced collection belongs in memory tests and benchmarks, where it makes measurements stable, and in one-off diagnostic scripts. Production processes must not run with --expose-gc: add a startup assertion or deployment check that fails if the production command line contains it, and a lint rule against gc calls in application code. Where is gc() being called? Allowed: stabilises before/after measurements tests and benchmarks Allowed for one-off investigations diagnostic scripts Fail the deploy check; lint forbids gc() in app code production process

Edge Cases and Gotchas

Finalizers run later

FinalizationRegistry callbacks are scheduled after the collection, as separate tasks, and are not guaranteed to run at all. Tests that assert on finalizer side effects must await several ticks after gc(), and production logic must never depend on them, as described in FinalizationRegistry callbacks that never run.

RSS may not drop

Collection frees heap space, but V8 and the system allocator may keep pages for reuse. Measure heapUsed for leak questions and treat RSS as a separate, slower-moving signal.

Minor-only collections leave old garbage

gc({ type: 'minor' }) collects only the young generation. Promoted garbage stays until a major collection. Use full collections for leak measurements.

Worker isolates are separate

Calling gc() in the main thread collects only the main isolate. Workers need their own --expose-gc (passed via execArgv) and their own calls.

Frequently Asked Questions

Can I force garbage collection in JavaScript?

Not in standard JavaScript. Node.js and Chrome can expose a non-standard gc() function via the --expose-gc flag, and DevTools and the DevTools Protocol can trigger collection from outside the page. These are tools for testing and diagnostics, not for application code.

Does calling gc() fix memory leaks?

No. Garbage collection only frees unreachable objects. A leak consists of objects that are still reachable, so forcing collection leaves them exactly where they are. It can only show you whether growth was garbage or retention.

Why is forcing GC in production bad?

A forced full collection blocks the main thread for time proportional to the live heap, at a moment V8 did not choose, and bypasses the incremental and concurrent machinery that normally hides that cost. The result is latency spikes with no lasting memory benefit.

Is the DevTools “Collect garbage” button the same as gc()?

It triggers a full collection in the inspected page or process, equivalent in effect to calling gc() there. Taking a heap snapshot also runs a full collection first, which is why snapshot totals are lower than live heap readings taken just before.