मुख्य सामग्री पर जाएँ
Clinford Jordano Valencia
सभी लेख

JavaScript

From Call Stack to Heap: How JavaScript Manages Memory

Where values live, how the garbage collector decides what to free, why reachability is the only rule that matters, and how to find and fix the leaks it can’t.

  • 10 मिनट में पढ़ें

यह पेज अभी हिन्दी में उपलब्ध नहीं है, इसलिए अंग्रेज़ी मूल पाठ दिखाया जा रहा है।

JavaScript has automatic memory management: you never free an object yourself. The garbage collector frees anything your program can no longer reach, and keeps everything it can. That single rule, reachability, explains how memory works, why leaks happen in a garbage-collected language, and how to find them. This post walks from the call stack to the heap, through how a modern collector works, to a practical leak-hunting workflow.

Why It Matters

Two kinds of memory problems reach users:

  • Leaks: a page that is fine for five minutes and sluggish after an hour, a dashboard tab that crashes overnight, a Node.js service whose memory climbs until it restarts.
  • Collection pauses: allocating lots of short-lived objects in a hot path causes frequent collections and visible jank, because collection competes with your code for the main thread (see the event loop).

Knowing the model helps you avoid both.

Stack and Heap

As a working model:

  • The call stack holds one frame per active function call (see execution contexts): its local variables and where to return to. Frames disappear as soon as the function returns.
  • The heap holds objects, arrays, closures, strings and everything else whose lifetime isn’t tied to a single call.

Variables on the stack (and properties of other objects) hold references into the heap. Engines are free to optimize this (V8 stores small integers directly, and may never allocate an object that doesn’t escape a function), but the model is enough to reason about lifetimes.

Reachability: The Only Rule

An object stays alive if there is a path of references to it from a root. Roots include global variables, the variables in every frame on the current call stack, and objects held by the runtime on your behalf: registered event listeners, active timers, pending promises with callbacks, the DOM tree attached to the document.

GlobalsCall stackListenersand timersuseraddressclosurebig arrayABunreachable cycle:collected
The collector keeps what is reachable from a root and frees the rest, even objects that still reference each other.

Two consequences:

  1. Cycles are fine. Two objects that reference each other but that nothing else reaches are collected. Modern collectors don’t count references; they trace from roots.
  2. One forgotten reference keeps a whole graph alive. If a long-lived listener holds a closure, and the closure holds a large array, the array lives as long as the listener does.

How the Collector Works

Modern engines such as V8 use a tracing, generational collector:

  1. Allocate new objects in a small young generation
  2. Scavenge it often: most objects die young and are freed cheaply
  3. Promote survivors to the old generation
  4. Mark everything reachable from roots, then sweep (and compact) the rest, mostly in the background

The design rests on the observation that most objects die young: temporary arrays, strings and closures created during one operation. Collecting the young generation is fast because it only touches the survivors. V8 does most marking concurrently on helper threads, so pauses on the main thread are short, but they are not free: allocating heavily in an animation or input handler still causes jank.

How Leaks Happen

A leak is memory that is still reachable but that you no longer need. The usual suspects:

LeakWhat keeps it aliveFix
Event listener never removedThe target (window, document, an emitter) holds the listener, which holds its closureremoveEventListener, or one AbortController per component
Timer never clearedThe runtime holds active intervalsclearInterval in cleanup
Unbounded cache or MapA long-lived Map holds every entry foreverSize limit or LRU eviction; WeakMap when keyed by objects
Detached DOM nodesA JS variable still points at removed elementsDrop the reference when the element is removed
Closure capturing too muchA long-lived callback captures a large object it barely usesCopy only what is needed into the callback
Accumulating module-level arraysLogs, history or subscriptions pushed foreverCap them, or clear them on navigation

Each one, with the leaking version and the fix side by side:

1. An Event Listener That Is Never Removed

window lives as long as the page. A listener added to it keeps its closure alive, and the closure keeps rows alive, so every time the table is mounted, another copy of the data stays in memory:

// Leaks: every mount adds a listener that is never removed
function mountTable(rows) {
  window.addEventListener('resize', () => layout(rows));
}
 
// Fixed: one AbortController removes every listener at once
function mountTable(rows) {
  const controller = new AbortController();
  window.addEventListener('resize', () => layout(rows), { signal: controller.signal });
  return () => controller.abort(); // call this when the table is removed
}

React effects, Vue onUnmounted and Angular ngOnDestroy exist largely to run this kind of cleanup.

2. A Timer That Is Never Cleared

The runtime holds every active interval, and the interval holds its callback, so chart can never be freed, even after it has left the page:

// Leaks: keeps polling, and keeps chart alive, forever
function startPolling(chart) {
  setInterval(async () => chart.update(await fetchStats()), 5000);
}
 
// Fixed: return a way to stop it
function startPolling(chart) {
  const id = setInterval(async () => chart.update(await fetchStats()), 5000);
  return () => clearInterval(id);
}

In React the cleanup goes in the effect:

useEffect(() => {
  const id = setInterval(refresh, 5000);
  return () => clearInterval(id); // runs on unmount
}, []);

3. A Cache That Only Grows

A module-level Map lives as long as the page or the server process. Keyed by URL, user or request, it gains an entry for every new key and never loses one:

// Leaks: one entry per distinct URL, kept forever
const cache = new Map();
async function getJson(url) {
  if (!cache.has(url)) cache.set(url, await (await fetch(url)).json());
  return cache.get(url);
}
 
// Fixed: keep at most 100 entries, dropping the least recently used
const MAX_ENTRIES = 100;
async function getJson(url) {
  if (cache.has(url)) {
    const value = cache.get(url);
    cache.delete(url); // re-insert so it counts as recently used:
    cache.set(url, value); // a Map keeps insertion order
    return value;
  }
  const value = await (await fetch(url)).json();
  cache.set(url, value);
  if (cache.size > MAX_ENTRIES) cache.delete(cache.keys().next().value); // the oldest key
  return value;
}

When the key is an object (a DOM element, a request object), use a WeakMap instead. The entry disappears when the key is collected, so there is nothing to evict:

const measurements = new WeakMap();
function measure(element) {
  if (!measurements.has(element)) measurements.set(element, element.getBoundingClientRect());
  return measurements.get(element);
}

4. Detached DOM Nodes

Removing an element from the page doesn’t free it while JavaScript still points at it. The element and everything inside it, including its listeners, stay in memory as a “detached” tree:

// Leaks: removed from the page, but still in openDialogs
const openDialogs = new Set();
function openDialog() {
  const dialog = document.createElement('dialog');
  document.body.append(dialog);
  openDialogs.add(dialog);
  return dialog;
}
function closeDialog(dialog) {
  dialog.remove();
}
 
// Fixed: drop the reference when the element goes
function closeDialog(dialog) {
  dialog.remove();
  openDialogs.delete(dialog);
}

5. A Closure That Captures More Than It Needs

The click handler below only needs a number, but it refers to report, so the whole report, with all its rows, stays alive as long as the button does:

// Leaks: keeps the whole report (say 50 MB of rows) alive
function attachSummary(button, report) {
  button.addEventListener('click', () => showCount(report.rows.length));
}
 
// Fixed: copy what the callback needs, so report can be freed
function attachSummary(button, report) {
  const count = report.rows.length;
  button.addEventListener('click', () => showCount(count));
}

Closures created in the same function can share one environment, so a sibling closure that uses report can keep it alive too. The closures post explains why.

6. Arrays That Only Ever Grow

Logs, undo history and analytics queues kept at module level grow for as long as the tab or process runs. That is invisible in a quick test and serious in a tab left open all week or a server that runs for months:

// Leaks: grows without limit
const history = [];
export function track(event) {
  history.push({ event, at: Date.now() });
}
 
// Fixed: keep only the most recent entries
const MAX_EVENTS = 500;
export function track(event) {
  history.push({ event, at: Date.now() });
  if (history.length > MAX_EVENTS) history.shift();
}

Weak References

Weak references let you point at an object without keeping it alive:

  • WeakMap and WeakSet hold their keys weakly. Use them to attach metadata to objects you don’t own (DOM nodes, objects from another library): when the object is collected, the entry disappears.
  • WeakRef holds one object weakly, and FinalizationRegistry lets you run cleanup after an object is collected. When collection happens is unpredictable, so use them only for optional work such as caches, never for correctness.

Finding a Leak

  1. Reproduce: repeat an action (open and close a dialog 20 times)
  2. Snapshot the heap before and after, with a garbage collection in between
  3. Compare: look for objects whose count grows with every repetition
  4. Follow the retainers from a leaked object back to a root
  5. Fix the reference, then repeat to confirm memory stays flat

The tools:

  • Chrome DevTools, Memory panel: heap snapshots, a “Comparison” view and a Retainers pane for any object.
  • Detached elements view: DOM nodes that are no longer in the document but still referenced.
  • Node.js: watch process.memoryUsage().heapUsed over time, then take heap snapshots with node --inspect and the same DevTools panel.

The retainer path is the most useful thing a heap snapshot shows: the chain of references from a root to the object you expected to be freed. Reading it from the leaked object back to the root tells you exactly which link to cut:

windowlistenerclosurecontextreport40 MBthe forgotten link:remove the listenereverything to the right becomes unreachableand is freed at the next collection
A retainer path read in DevTools. Every link keeps the next one alive; removing any one of them (here, the forgotten listener) frees everything after it.

Fixing and Preventing Leaks

Once the retainer path shows the link that keeps memory alive, the fix depends on what that link is:

The retainer path ends inFix
An event listener on window, document or an emitterRemove it on cleanup; one AbortController per component removes every listener at once
An active setInterval or setTimeoutClear it on cleanup (clearInterval, clearTimeout)
A module-level Map, Set or arrayBound it: a size limit, LRU eviction or expiry; use WeakMap when the key is an object
A detached DOM treeDrop every JavaScript reference to the removed element
A closure holding a large objectCopy only the values the callback needs
A subscription (store, WebSocket, observer)Unsubscribe on cleanup; most APIs return an unsubscribe function

Then make it stick:

  1. Confirm the fix. Repeat the same action, force a collection and compare snapshots again: the count of the leaked objects should stay flat.
  2. Give every component one cleanup path. Whatever it starts (listeners, timers, subscriptions, requests), it stops in one place: a React effect’s return function, Vue’s onUnmounted or Angular’s ngOnDestroy.
  3. Bound everything long-lived. No module-level collection without a maximum size or an expiry.
  4. Test the leak you fixed. An end-to-end test can repeat the action many times and compare the JavaScript heap before and after (for example through the Chrome DevTools Protocol), so the same leak can’t return quietly.
  5. Watch production. Track heapUsed and rss for Node.js services and alert on steady growth between restarts; in the browser, performance.measureUserAgentSpecificMemory() reports field data on cross-origin isolated pages.

Going Deeper: Memory the Heap Snapshot Doesn’t Show

Not All Memory Is on the JavaScript Heap

ArrayBuffers, Node.js Buffers, images, canvases and WebAssembly memory are allocated outside the JavaScript heap; the heap only holds a small object pointing to them. Allocating a 50 MB Buffer in Node.js adds about nothing to heapUsed and 50 MB to arrayBuffers:

const before = process.memoryUsage();
const buffer = Buffer.alloc(50 * 1024 * 1024);
const after = process.memoryUsage();
// heapUsed: about +0 MB, arrayBuffers: +50 MB, rss: up as well

So when a process’s total memory (rss) climbs while heap snapshots look flat, look at external and arrayBuffers: retained buffers, streams that are never consumed, or image data kept in a cache.

Heap Limits in Node.js and Containers

V8 sets a maximum size for the old generation based on available memory.

  • Over the limit: the process fails with FATAL ERROR: … JavaScript heap out of memory, often after long garbage collection pauses as V8 works harder and harder to free space.
  • In containers: the container’s memory limit and V8’s limit are separate. If V8’s is higher, the kernel kills the process first, with no JavaScript error at all.
  • The fix: set --max-old-space-size (in MB) somewhat below the container limit, leaving room for buffers and native memory, so failures are visible and attributable.

Memory in the Browser

  • performance.measureUserAgentSpecificMemory() reports the page’s memory usage in the field, but only on cross-origin isolated pages and in Chromium.
  • The DevTools console keeps references to everything you log. A console.log(hugeObject) while debugging can make a leak look worse, or create one, until the console is cleared.
  • Single-page apps leak through navigation: listeners, intervals and subscriptions created by a page that are never cleaned up when the user moves on. Repeating “open page, leave page” ten times while watching the heap is the quickest check.

Test Yourself

Each snippet runs in a long-lived page or server. Decide whether it leaks, then open the answer.

चुनौती 1

A Cache for User Profiles

const profiles = new Map();
async function getProfile(id) {
  if (!profiles.has(id)) profiles.set(id, await fetchProfile(id));
  return profiles.get(id);
}
उत्तर देखें

It grows without limit.

  • Every distinct id adds an entry that is never removed, and the module-level Map lives as long as the process. On a server with millions of users, that is a slow leak.
  • Fix: bound it (an LRU with a maximum size, or a time-based expiry), or keep the cache per request.
  • A WeakMap wouldn’t help: the keys are strings, not objects.

चुनौती 2

Two Objects That Point to Each Other

let a = { name: 'a' };
let b = { name: 'b' };
a.peer = b;
b.peer = a;
a = null;
b = null;
उत्तर देखें

No leak.

  • After both variables are set to null, nothing reachable from a root points to either object.
  • Tracing collectors don’t care that they point to each other; they free unreachable cycles.
  • The myth comes from reference-counting collectors, such as older Internet Explorer’s for DOM objects, which did leak cycles.

चुनौती 3

Metadata for DOM Nodes

const tooltips = new WeakMap();
function attachTooltip(element, text) {
  tooltips.set(element, { text, shownAt: null });
}
// later the element is removed with element.remove()
उत्तर देखें

No leak, as long as nothing else holds the element.

  • A WeakMap doesn’t keep its keys alive: once the removed element is unreachable, the entry and its value are collected with it.
  • With a regular Map, the map itself would keep every removed element alive, as a detached DOM tree.

चुनौती 4

Polling Inside a Component

function mountDashboard(container) {
  const state = { rows: loadRows() }; // a large array
  setInterval(() => render(container, state.rows), 5000);
}
उत्तर देखें

It leaks every time the dashboard is mounted.

  • The interval is never cleared, so the runtime keeps the callback, which keeps state (with all its rows) and container alive after the dashboard is gone.
  • Each remount starts another interval.
  • Fix: return a cleanup function that calls clearInterval, and call it on unmount.

चुनौती 5

Debugging With console.log

button.addEventListener('click', () => {
  const report = buildReport(); // 30 MB
  console.log(report);
});
उत्तर देखें

While DevTools is open, yes.

  • The console keeps a reference to every logged object so you can expand it later, so each click retains another 30 MB until the console is cleared.
  • Users without DevTools open are unaffected, but it can mislead your own measurements.
  • Fix: log summaries (report.length), and clear the console before taking heap snapshots.

Key Takeaways

  • The garbage collector keeps everything reachable from roots (globals, the call stack, listeners, timers) and frees the rest, including unreachable cycles.
  • A leak is a reference you forgot, usually a listener, timer, cache, detached DOM node or long-lived closure.
  • Most objects die young, so short-lived allocations are cheap, but heavy allocation in hot paths still causes pauses.
  • Use AbortController for listener cleanup, bounded caches, and WeakMap for data attached to objects you don’t own.
  • Find leaks by repeating an action, comparing heap snapshots and following the retainer path to the root.

Sources

टैग

अक्सर पूछे जाने वाले सवाल

Are primitives on the stack and objects on the heap?

YesAs a mental model, roughly. Engines decide where values actually live, and many small values are optimized away entirely. What matters for your code is reachability, not the physical location.

Should I set variables to null to free memory?

NoRarely. Local variables become unreachable when a function returns. Setting a long-lived reference to null, or deleting an entry from a long-lived Map, is useful when that reference would otherwise keep a large object alive.

When should I use WeakMap or WeakRef?

Use WeakMap to attach data to objects you don’t own without keeping them alive. WeakRef and FinalizationRegistry are for rare cases such as caches of expensive objects, because when the garbage collector runs is unpredictable.

What is the difference between heapUsed and rss in Node.js?

heapUsed is memory used by JavaScript objects on the V8 heap. rss is everything the process holds in RAM, including the heap, buffers and array buffers outside it, native code and stacks. A growing rss with a flat heap points to memory outside the JavaScript heap.

What does “JavaScript heap out of memory” mean?

The V8 heap reached its size limit and garbage collection could not free enough space. Either the program needs more memory (raise --max-old-space-size, staying below the container limit) or, more often, something is retaining memory it no longer needs.

Why does memory not drop right after I close a dialog?

The garbage collector runs when it decides to, not immediately. To check for a leak, repeat the action several times, force a collection in DevTools, and compare heap snapshots.

How do I check a single-page app for leaks?

Repeat a navigation (open a page, leave it) ten times, force a collection, and compare heap snapshots. Objects whose count grows with every repetition, often listeners, intervals or detached DOM nodes, are the leak.

Can garbage collection pauses cause jank?

YesEven with concurrent marking, the main thread pauses briefly, and allocating many objects in animation or input handlers causes frequent collections. Reuse objects and avoid allocating in hot loops when frames matter.