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.
Step-by-Step Fix
- Expose GC only where you measure. Add
--expose-gcto 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;undefinedin production. - Collect before every measurement. Call
gc(), await a macrotask, and call it again, then readprocess.memoryUsage().heapUsed. Verification: repeated readings on an idle process agree within a few KB. - 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.
- Force GC from automation. In Puppeteer or Playwright, send
HeapProfiler.collectGarbageover a CDP session before readingJSHeapUsedSize. Verification: browser-side memory tests become stable, as in reducing noise in automated memory tests. - Remove any production
gc()calls. Search the codebase forgc(and--expose-gcin deployment configuration. Verification: no production path forces collection; memory behaviour is governed by V8 heuristics and your heap limits. - 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.
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.
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.
Related
- How Mark-and-Sweep Garbage Collection Works — the parent topic
- Writing Memory Leak Tests with Vitest and --expose-gc — putting forced GC to work in tests
- How to Tune V8 Garbage Collection Thresholds for SPAs — influencing GC without forcing it
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview