Memory management & garbage collection
Learn how JavaScript allocates, uses, and releases memory, how mark-and-sweep finds unreachable objects, and how common leaks happen.
- 01Explain the lifecycleDescribe allocate, use, and release without claiming JavaScript frees memory manually.
- 02Trace reachabilityStart from roots, follow references, and predict what mark-and-sweep can collect.
- 03Spot common leaksFix long-lived listeners, intervals, closures, caches, and detached DOM node references.
Memory you can reason about, not manually free
The stack-and-heap lesson gave you a careful mental model: variables can point to heap objects, and reachability decides whether an object is still needed. The closures lesson showed that functions can keep variables alive after their creator returns. Memory management joins those ideas and applies them to real frontend code.
Memory management is the process of allocating values, using them while they are reachable, releasing unneeded references, and letting the JavaScript engine garbage-collect objects that can no longer be reached from roots.
You never call free() for ordinary JavaScript objects. You design code so unneeded objects stop being reachable: remove event listeners, clear timers, cap caches, and stop storing DOM nodes you removed. The exact garbage-collection run remains the engine's decision.
Allocate, use, release
STEP THROUGHThe useful habit is not “free the object.” It is “unpack the paths that keep this object useful.” Once no root can reach it, the object is eligible for cleanup.
- In real life: Put a notebook in the backpack
- In JavaScript: Allocate an object and keep a reference
- In real life: Read from the notebook during class
- In JavaScript: Use the object while it is reachable
- In real life: Remove the notebook when the class ends
- In JavaScript: Release the reference you no longer need
- In real life: Lost-and-found decides when to recycle unclaimed items
- In JavaScript: The engine chooses when garbage collection actually runs
Where the analogy stops: A backpack has a visible owner and a predictable cleanup time. JavaScript reachability can include globals, closures, host queues, and DOM structures, and garbage collection is not scheduled by your code.
| Phase | What your code does | Careful wording |
|---|---|---|
| Allocate | Create objects, arrays, functions, DOM nodes, and closures as code runs. | You do not reserve raw memory manually. |
| Use | Read and write reachable values through variables, object properties, callbacks, or DOM references. | Reachable data must stay alive. |
| Release | Remove references you no longer need: clear slots, remove listeners, clear timers, cap caches. | This makes objects eligible; GC timing is still automatic. |
Step through this small feature. It creates a preview object, reads and mutates it, then removes the local reference. The console output is predictable; the later collection moment is not.
Follow the memory lifecycle: allocate an object, use it, remove a reference, and let reachability decide what can be collected later.
script
function openPreview() { let preview = { title: "Cart", rows: ["hat", "bag"] }; const label = preview.title.toUpperCase(); preview.rows.push("coat"); preview = null; return label;} Line 5 is the subtle line. preview = null is useful because it removes one path, but it is not a command to the garbage collector. If another root still reached the object, it would stay alive.
Roots and reachability
MODELGarbage collectors start from roots: values the engine and host know must stay alive. From those roots, the collector follows references. If a root can reach an object, the object must be kept. If no root can reach it, the object is eligible for collection.
Start with people already in the group and follow shared contacts. If you can reach someone through that chain, keep the object. If not, the object is unreachable in this model.
- In real life: People already in the group
- In JavaScript: Roots such as globals and active stack frames
- In real life: A contact shared in a message
- In JavaScript: Object properties that reference other objects
- In real life: Nobody reachable through the group
- In JavaScript: An unreachable object
- In real life: Removing unused information
- In JavaScript: Garbage collection
Where the analogy stops: Messages do not control when a phone cleans storage. JavaScript references change while code runs.
| Root | Why it can retain memory |
|---|---|
| Global object | Globals and module-level variables can keep values reachable for the page lifetime. |
| Current call stack | Local variables in active functions are roots while those functions are running. |
| Active closures and handlers | A stored callback keeps the variables it closes over reachable. |
| Host structures | Event targets, timer queues, pending tasks, and the DOM can retain callbacks or nodes. |
Generational collectors add an optimization on top of this model: most objects die young, so engines usually check young generations more often and promote survivors to older generations. That overview is enough here; engine internals differ, and normal code still reasons with reachability.
Mark and sweep, including cycles
SIMULATIONA simple reference-counting collector counts incoming references and frees an object when the count reaches zero. That idea breaks down with cycles: two objects can point at each other even after the rest of the program has lost them. Mark-and-sweep avoids that problem by starting from roots instead of trusting local counts.
Step through a real JavaScript mark-and-sweep simulation. Watch roots mark reachable objects and an unreachable cycle get swept.
script
function markAndSweep(heap, roots) { const marked = new Set(); const worklist = [...roots]; while (worklist.length > 0) { const id = worklist.pop(); if (!id || !heap[id] || marked.has(id)) continue; marked.add(id); for (const ref of heap[id].refs) worklist.push(ref); } const swept = Object.keys(heap).filter((id) => !marked.has(id)); return { marked: [...marked], swept };} app: { refs: ["modal"] }, modal: { refs: ["user"] }, user: { refs: ["profile"] }, profile: { refs: [] }, cycleA: { refs: ["cycleB"] }, cycleB: { refs: ["cycleA"] },};const roots = ["app"];const result = markAndSweep(heap, roots);console.log(result.marked.join(", "));console.log(result.swept.join(", "));In the graph, cycleA and cycleB point at each other. A pure reference count would see one incoming reference for each object. The mark phase starts from app, never reaches the cycle, and the sweep phase can collect the whole unreachable island. This is still a simulation: it shows eligibility, not the browser's exact GC moment.
Leaks from listeners, timers, closures, and caches
PLAYGROUNDA leak is not “an object exists.” A leak is “something long-lived still reaches data the app no longer needs.” Frontend leaks often involve host roots: event targets, timers, retained closures, and caches that never shrink.
const roots = ["app", "window", "timerQueue"]; function createMountedHeap() { return { app: { refs: ["component"] }, window: { refs: ["resizeHandler"] }, timerQueue: { refs: ["intervalCallback"] }, component: { refs: ["bigRows"] }, resizeHandler: { refs: ["bigRows"] }, intervalCallback: { refs: ["bigRows"] }, bigRows: { refs: [] }, };} function reachableIds(heap) { const seen = new Set(); const worklist = [...roots]; while (worklist.length) { const id = worklist.pop(); if (!heap[id] || seen.has(id)) continue; seen.add(id); worklist.push(...heap[id].refs); } return [...seen].filter((id) => !roots.includes(id));} function simulateUnmount(cleanup) { const heap = createMountedHeap(); heap.app.refs = []; if (cleanup) { heap.window.refs = []; heap.timerQueue.refs = []; } return reachableIds(heap);} console.log(simulateUnmount(false).join(", "));console.log(simulateUnmount(true).length);3 reachable simulated objects
- intervalCallback
retained - bigRows
retained - resizeHandler
retained
The component was removed, but window and timerQueue still reach callbacks that close over bigRows.
Without cleanup, the simulated window listener and timerQueue still reach callbacks that close over bigRows. With cleanup, those roots are cut and the retained count drops to zero.
AbortControllerconst panel = document.querySelector("#listener-panel");const controller = new AbortController();const rows = ["Ada", "Lin"]; function renderRows() { console.log(rows.join(", "));} panel.addEventListener("click", renderRows, { signal: controller.signal });panel.click();controller.abort();panel.click();console.log("listener removed");A listener on a long-lived target such as window can retain its callback and everything the callback closes over. Use removeEventListener, a listener option such as { once: true }, or an AbortController signal when the same cleanup action removes several listeners.
const rows = ["Ada", "Lin", "Grace"];const intervalId = setInterval(() => { console.log(rows.length); clearInterval(intervalId); console.log("interval cleared");}, 0);A setInterval callback remains reachable from the timer queue until you call clearInterval. If it closes over a large array, DOM node, or old component state, that data remains reachable too.
function makeLeakyHandler() { const bigReport = ["sales", "inventory", "returns"]; return () => console.log(bigReport.length);} function makeSmallHandler() { const rowCount = ["sales", "inventory", "returns"].length; return () => console.log(rowCount);} makeLeakyHandler()();makeSmallHandler()();The first handler captures a whole array. The second handler computes the primitive count and captures only that. The best fix is often to move large data out of the closure, not to avoid closures altogether.
Map cacheconst cache = new Map(); function remember(key, value) { cache.set(key, value); if (cache.size > 2) { const oldestKey = cache.keys().next().value; cache.delete(oldestKey); }} remember("Ada", { visits: 1 });remember("Lin", { visits: 2 });remember("Grace", { visits: 3 });console.log([...cache.keys()].join(", "));WeakMap for object metadataconst metadata = new WeakMap();let card = { id: "promo" };metadata.set(card, { measured: true }); console.log(metadata.has(card));card = null;console.log("WeakMap keys are weakly held");A normal Map strongly holds keys and values, so size caps, deletion, and clearing are real cleanup tools. A WeakMap is useful when metadata should not keep object keys alive by itself. You still cannot observe the exact moment a key is collected.
`button.addEventListener("click", save, { once: true })`- A
resizelistener onwindowcloses over page data and is never removed. - A
setIntervalcallback closes over rows and the ID is never cleared. - Metadata keyed by DOM nodes lives in a
WeakMap. - A normal
Mapcache deletes its oldest entry after two items. - An array stores a removed DOM node for debugging and is never cleared.
Sort each card by whether it is cleaned, retained until explicit cleanup, or controlled with weak/bounded storage.
Detached DOM nodes
RUN CONTEXTA DOM node is detached when it is no longer inside the document tree. It can still be reachable from JavaScript. This matters in UI code that stores old nodes in arrays, debug globals, module caches, event callbacks, or closure state after calling DOM methods like remove().
The runnable example below uses the lesson's RunContext page, which provides #leak-card. It removes that card from document, then proves the array still holds it.
const retained = [];const card = document.querySelector("#leak-card"); retained.push(card);card.remove(); console.log(document.contains(card));console.log(retained.length);console.log(retained[0].textContent.trim()); retained.length = 0;console.log(retained.length);The important pair is document.contains(card) === false and retained.length === 1. The node is not in the document, but it is still reachable. Clearing the array removes this snippet's extra reference.
Finding leaks with Chrome DevTools
Normal page JavaScript cannot reliably ask “did GC run now?” Use tooling for leak investigations. In Chrome DevTools, open the Memory panel and collect evidence over repeated actions: mount and unmount a component, navigate away and back, or open and close a modal several times.
- Heap snapshot: take one snapshot, perform the suspected leak action, take another, then switch to the Comparison view to find constructors whose retained counts grew.
- Detached nodes: use the snapshot class filter for detached DOM terms and the Detached elements profile type when you want a focused list of elements retained by JavaScript references.
- Allocation instrumentation on timeline: record allocations over time, select an interval, and inspect objects allocated in that interval that are still live at the end of the recording.
- Allocation sampling: use it for lower-overhead, longer-running sessions when you need an approximate allocation breakdown by stack.
performance.memory is non-standard and Chrome-specific, so treat it as a diagnostic hint. performance.measureUserAgentSpecificMemory() is the newer browser measurement API, but it requires cross-origin isolation headers before a normal page can use it. Neither lets app code depend on deterministic collection.
// Chrome-only and non-standard; do not build logic on this.
console.log(performance.memory);
// Needs cross-origin isolation headers before it is available.
if (crossOriginIsolated && "measureUserAgentSpecificMemory" in performance) {
const result = await performance.measureUserAgentSpecificMemory();
console.log(result.bytes);
}Common misconceptions
| Misconception | More accurate wording |
|---|---|
Setting a variable to null frees memory immediately | It removes one reference. Other paths may exist, and collection timing is automatic. |
| Cycles always leak | Pure reference counting has trouble with cycles, but mark-and-sweep collects unreachable cycles. |
| Closures are leaks | Closures are normal. A leak happens when a long-lived function keeps data you no longer need. |
performance.memory is a portable answer | It is non-standard and Chrome-specific; do not build app logic on it. |
| A detached node is gone | It is gone from the document tree, but any JavaScript reference can still retain it. |
“If I set it to null, memory is gone.”
You removed one path. Other roots may still reach the object, and collection timing remains automatic.
“Cycles always leak.”
Unreachable cycles are collectible with mark-and-sweep. A cycle leaks when a root still reaches it.
“Closures are bad for memory.”
Closures are normal. Long-lived callbacks that capture unnecessary data are the pattern to fix.
“DevTools numbers are exact proof of a single object being freed.”
Use snapshots, comparisons, and allocation recordings to find trends and retained paths, not to write app logic around exact GC moments.
Practice: reason from roots
5 EXERCISESType the exact value printed by the program.
function openPreview() {
let preview = { title: "Cart", rows: ["hat", "bag"] };
const label = preview.title.toUpperCase();
preview.rows.push("coat");
preview = null;
return label;
}
console.log(openPreview());It prints CART. The local reference is cleared after label is created, and the function returns that primitive string.
Which object IDs does the simulated sweep return?
function markAndSweep(heap, roots) {
const marked = new Set();
const worklist = [...roots];
while (worklist.length > 0) {
const id = worklist.pop();
if (!id || !heap[id] || marked.has(id)) continue;
marked.add(id);
for (const ref of heap[id].refs) worklist.push(ref);
}
return Object.keys(heap).filter((id) => !marked.has(id));
}
const heap = {
app: { refs: ["modal"] },
modal: { refs: [] },
cycleA: { refs: ["cycleB"] },
cycleB: { refs: ["cycleA"] },
};
console.log(markAndSweep(heap, ["app"]).join(", "));It prints cycleA, cycleB. app reaches modal, but no root reaches either object in the cycle.
Name one cleanup technique that would prevent the listener from retaining rows.
function mount(button, rows) {
function render() {
console.log(rows.length);
}
button.addEventListener("click", render);
return () => {
// add cleanup here
};
}button.removeEventListener("click", render);
// or create an AbortController and pass { signal } when addingThe cleanup must remove or abort the listener so the long-lived target no longer stores the closure.
What cleanup call stops this interval?
const id = setInterval(() => {
console.log("tick");
}, 1000);
// write the cleanup callclearInterval(id);clearInterval(id) removes the interval callback from the timer queue, so it stops retaining captured data.
After card.remove(), what does document.contains(card) print?
const retained = [];
const card = document.querySelector("#leak-card");
retained.push(card);
card.remove();
console.log(document.contains(card));
console.log(retained.length);document.contains(card) prints false. The node is detached, but retained.length would be 1 because the array still holds it.
Quiz: memory management and leaks
8 QUESTIONSQuestion 1 of 8Which sentence best defines JavaScript memory management?
Choose an answer to see the explanation.
Question 2 of 8What does this lifecycle code print?
Read the code, then predictfunction openPreview() { let preview = { title: "Cart", rows: ["hat", "bag"] }; const label = preview.title.toUpperCase(); preview.rows.push("coat"); preview = null; return label; } console.log(openPreview());Choose an answer to see the explanation.
Question 3 of 8Which values are roots in the reachability model?
Choose an answer to see the explanation.
Question 4 of 8What does the simulated sweep print?
Read the code, then predictfunction markAndSweep(heap, roots) { const marked = new Set(); const worklist = [...roots]; while (worklist.length > 0) { const id = worklist.pop(); if (!id || !heap[id] || marked.has(id)) continue; marked.add(id); for (const ref of heap[id].refs) worklist.push(ref); } return Object.keys(heap).filter((id) => !marked.has(id)); } const heap = { app: { refs: ["modal"] }, modal: { refs: [] }, cycleA: { refs: ["cycleB"] }, cycleB: { refs: ["cycleA"] }, }; console.log(markAndSweep(heap, ["app"]).join(", "));Choose an answer to see the explanation.
Question 5 of 8Why did old reference-counting collectors struggle with cycles?
Choose an answer to see the explanation.
Question 6 of 8Which cleanup stops a long-lived window listener from retaining its closure?
Choose an answer to see the explanation.
Question 7 of 8What does this interval snippet print before it clears itself?
Read the code, then predictconst rows = ["Ada", "Lin", "Grace"]; console.log(rows.length);Choose an answer to see the explanation.
Question 8 of 8A DOM node was removed from the document, but an array still stores it. What is true?
Choose an answer to see the explanation.
Key takeaways
- JavaScript allocates objects as code runs and automatically collects unreachable ones later.
- Reachability starts at roots: globals, active stack frames, host queues, closures, handlers, and DOM structures.
- Mark-and-sweep collects unreachable cycles because it traces from roots instead of relying on local reference counts.
- Leaks usually come from long-lived roots: listeners, intervals, closures, unbounded caches, and retained detached DOM nodes.
- Use Chrome DevTools Memory tools to investigate; do not build app behavior around exact GC timing.
One rule to keep:
If a root can still reach an object, JavaScript must keep it; when no root can reach it, the object is eligible, not synchronously freed.
Up next: WeakRef & FinalizationRegistry, where the same nondeterministic timing warning becomes the main design constraint.