Garbage collection and memory leaks
GC frees the unreachable, not the unused: a leak is a forgotten reference path from a root.
Predict first
Before you read: predict
EXH-361 "Proctor console grows and slows over a session". Candidate c-101's network drops twice, so the console connects c-101's tile three times. Then c-101 submits and the proctor closes the tile. No variable names any frames array any more. Run with node --expose-gc. collect() runs gc() three times, each in a later task.
What do the two lines print?
const socket = new EventEmitter(); // one shared socket for the whole console function connectTile(candidateId) { const frames = Array.from({ length: 1_000 }, (_, n) => `${candidateId}:${n}`); socket.on('alert', (alert) => frames.push(alert)); return new WeakRef(frames); // instrumentation: lets us ask later if frames exists} const refs = [connectTile('c-101'), connectTile('c-101'), connectTile('c-101')];await collect();console.log(`listeners: ${socket.listenerCount('alert')}`);console.log(`frame buffers alive: ${refs.filter((r) => r.deref() !== undefined).length}`);The 30-second version
The garbage collector frees an object only when no chain of references reaches it from a root: globals, the running stack, live timers. on() adds a listener; it never replaces one. A forgotten listener on a reachable emitter, a live interval or a cache entry keeps its closure reachable, and the closure keeps everything it references. Cut every path.
GC frees the unreachable, not the unused: a leak is a forgotten reference path from a root.
How it works
Step 1 of 7
Analogy: A bank's settlement back office (finance and legal, bank settlement)
The back office closes a dormant account only when no chain of links from an active customer or a live standing order still reaches it.
| In the analogy | In the system |
|---|---|
| Active customers, and standing orders the bank's own scheduler runs | Roots (globals, stack, live timers) |
| The clerk starts at each active customer and follows every linked account, mandate and sub-account, flagging each | Mark: transitive reachability |
| Unflagged accounts are closed, whether anyone still "wants" them or not | Sweep: reachability decides, not usefulness |
| A direct-debit mandate on an active account, never cancelled, that still links an old escrow account. Close the active account and its mandates go with it | A listener on a reachable emitter keeps its closure's data. The listener is not a root |
| A standing order the scheduler keeps paying into an old account | A live setInterval roots its callback |
| A "keep on file" register that clerks only ever add to | A Map cache with no delete |
| A note "if account 7 is still open, use it", which puts no hold on it | WeakRef / WeakMap key |
Breaks down when:
- The back office closes accounts at a posted end-of-day run. GC timing is the engine's choice, so never infer "collected" from elapsed time.
- A dormant account costs fees that someone notices, and the bank writes to the customer first. A leak is silent, and the collector asks no one.
Contrasting case: reference counting. A strategy that frees an object when its count of incoming references reaches 0 never frees two objects that point at each other, even when nothing else reaches them. Mark-and-sweep does free them, because it asks only "reachable from a root?".
In real terms: the collector frees what no root can reach, and a leak is an unwanted path from a root.
Examples, run for real
Example 1: ExamHall, EXH-361 and the MR !719 fix. Each reconnect adds an alert listener and a 60 s heartbeat setInterval, and both callbacks close over the tile's frame buffer. Three strategies, three connections each, then one alert. File: e1-reconnect.mjs, excerpt:
function connect(candidateId) {
if (current) {
if (abortListener) current.controller.abort(); // removes the { signal } listener
if (clearHeartbeat) clearInterval(current.heartbeat);
}
const frames = Array.from({ length: 1_000 }, (_, n) => `${candidateId}:${n}`);
const controller = new AbortController();
socket.addEventListener('alert', () => { handlerRuns++; frames.push('alert'); }, { signal: controller.signal });
const heartbeat = setInterval(() => frames.length, 60_000);
current = { controller, heartbeat, ref: new WeakRef(frames) };
}
leaky : handler runs per alert 3, intervals 3, old buffers alive 2 of 2
abort only : handler runs per alert 1, intervals 3, old buffers alive 2 of 2
abort + clear: handler runs per alert 1, intervals 1, old buffers alive 0 of 2
Aborting fixes the triple alert but not the memory, because the intervals still root the old buffers (step 5). The buffers are collected only after both paths are cut. 50 runs + 200 at 8 in parallel: 1 distinct output.
Example 2: catalog, a price-badge cache. 500 products per category page, each with a 1,000-item gallery. One page's badges are cached in a Map, one in a WeakMap, and one in a Map from SKU to WeakRef. Then the shopper leaves all three pages. File: e2-catalog-cache.mjs.
Map: entries 500, first product alive true
WeakMap: first product alive false
Map of WeakRefs: entries 500, products alive 0
WeakMap string key: TypeError: Invalid value used as weak map key
The Map keeps every product, gallery included. The WeakMap lets the products go. The WeakRef values let the products go, but the 500 entries stay: bound the map with an LRU or a FinalizationRegistry cleanup. WeakMap keys must be objects (or non-registered symbols), which is why the string key throws. 50 + 200 runs: 1 distinct output.
Example 3: chat, a detached message node (browser-only). A "recently rendered" array, kept for scroll restore, is pushed to and never trimmed. Two messages render, only msg-1 is remembered, then both leave the list (list.replaceChildren()). File: e3-chat-detached.page.js, run by e3-run-chromium.cjs, with GC forced over CDP (HeapProfiler.collectGarbage, three passes in later tasks).
kept <li>: alive true, isConnected false; dropped <li>: alive false | queryObjects(MessageView): 1
msg-1's <li> is detached: it is out of the document and still in memory, because recentlyRendered → MessageView → node is a path from a root. Headless Chromium 153, 50 fresh contexts: 1 distinct output. This is verified in Chromium only. Firefox and Safari were not run.
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
(p1-ticketing-hold.mjs, --expose-gc).
Which hold(s) can never be collected at this point?
function holdSeat(seat) { const hold = { seat, seatMap: new Array(1_000).fill(seat) }; const countdown = setInterval(() => hold.seatMap.length, 1_000); countdown.unref(); // lets the demo exit; the interval keeps running return { release: (clearTimer) => { if (clearTimer) clearInterval(countdown); } };}let a = holdSeat('A-12'), b = holdSeat('B-07');a.release(false); // released without clearing the countdownb.release(true); // released and cleareda = b = null;// here: which seat holds can never be collected while the process runs?Question 2 of 5
A payments API computes a fraud score per incoming req object. The score must be available only while that request is being handled, and no code may have to delete it.
Where do you store it?
Question 3 of 5
(p3-streaming-cycle.mjs, --expose-gc).
Which session(s) can never be collected at this point?
const recentlyClosed = []; // module-level "reopen last player" list, never trimmedfunction openPlayer(id, remember) { const session = { id, segments: new Array(1_000).fill('seg') }; const player = { session }; session.player = player; // session <-> player cycle if (remember) recentlyClosed.push(player);}openPlayer('s-9', false);openPlayer('s-10', true); // the viewer has closed both players// here: which session can never be collected?Question 4 of 5
A payments dashboard's charges widget builds rows (1,000 entries) and view = { id: 'charges', rows } when it opens, and runs openWidgets.add(view) on a module-level Set. It registers three callbacks that all read rows, on a module-level EventTarget called bus and on the timer list. The fx-reset event is sent only when an admin resets exchange rates, which has not happened this session. At t = 1000 the widget's close() runs ac.abort(); openWidgets.delete(view);, yet a heap snapshot still shows rows.
Which line keeps rows reachable?
Question 5 of 5
A ticket queue page runs const handle = startQueuePoller();. Inside, startQueuePoller keeps state = { positions } and calls setTimeout(poll, 2000), returning that timer's id; the page stores nothing else from it. Each poll() pushes the fan's position into state.positions and then calls setTimeout(poll, 2000) again. The page's leave() is empty, so after the fan leaves at t = 5000 the page keeps polling.
Which one change to leaving stops the polling and leaves no path from any root to state?
Go deeper
Pro tips
- Trick: run Node with
--trace-warningswhenMaxListenersExceededWarningappears, and read the first user frame of the stack: it is theon()call that keeps adding listeners. When it helps: step 4. A reconnect or remount path adds a listener each time. Verify:node --trace-warnings tip1-maxlisteners.mjs(11on()calls). Without the flag: 1 warning line naming "11 alert listeners … MaxListeners is 10". With it: a stack whose first user frame isat connectTile (…tip1-maxlisteners.mjs:4:35). Status: RAN, Node 24.19.0, 50 runs each, 1 distinct (PID normalised). Node warns from the 11th listener per event name for bothEventEmitterandEventTarget(tip1-eventtarget.mjs: "Possible EventTarget memory leak detected. 11 alert listeners added to EventTarget"; with--trace-warningsits first user frame isat connectTile (…tip1-eventtarget.mjs:3:35)). Browsers never warn. - Trick: in the DevTools Console, run
queryObjects(MessageView)before and after an action that should free views. A count that stays up is a leak candidate. Then take a heap snapshot and filter Objects retained by detached DOM nodes. When it helps: step 7, and a long-lived tab that grows. Verify: after example 3, the count is1(msg-1's view, kept byrecentlyRendered). Status: RAN via CDPRuntime.queryObjectsin headless Chromium 153 (50 contexts, 1 distinct). The DevTools console command and the snapshot filter were not clicked in the UI: DOC (console utilities, heap snapshots, both bank tips DEV-06 and DEV-07; not re-fetched today).
Source
| Link | Used for | Valid as of |
|---|---|---|
| MDN, Memory management: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Memory_management | steps 1–2, the reference-counting contrasting case (mark-and-sweep from roots and cycles), weak references | 2026-09-27 (fetched: the page exists; it states that mark-and-sweep starts from roots and that it solves the cycle problem of reference counting) |
| MDN, WeakMap: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap | step 6, example 2, P2 | 2026-09-27 (fetched: keys must be objects or non-registered symbols; no enumeration, because liveness must not be observable) |
Checked against the tools and docs as of 2026-09-27.