Memory Management
JavaScript hides memory from you on purpose. You never call malloc/free and you can't directly delete an object. The engine allocates memory when you create values and reclaims it when nothing can reach those values anymore. That is convenient, but it does not mean memory leaks are impossible — they just look different. This page walks through the rough shape of the heap and stack, where memory comes from, and the patterns that quietly hold on to it longer than you intended.
Where values live: stack vs heap
At a sketch level the engine keeps two memory regions while your code runs:
The call stack — small, fast, last-in-first-out. Each function call pushes a frame holding local variables and a return address. When the function returns, the frame pops.
The heap — a larger, unstructured area where objects, arrays, functions and closures live. Their lifetime is decoupled from any single call.
Primitive values like numbers and booleans are typically kept inline in stack frames or in object slots. Objects are always allocated on the heap; variables on the stack just hold a reference — a pointer — into that heap.
references vs values
function demo() {
const n = 42; // primitive, lives on the stack
const user = { // object, lives on the heap
name: "Ada",
};
const alias = user; // same heap object, two references
alias.name = "Ada L.";
console.log(user.name); // "Ada L."
}Reassigning alias to something else would not affect user — they are two independent slots that happened to point at the same object. Understanding that "variables hold references, not objects" makes a lot of memory questions click.
Automatic allocation
You allocate memory just by writing code. Every literal, every operator that produces a new object, every call that creates an array or string, every closure — all of them ask the engine for memory in the background.
implicit allocations
const a = []; // new array
const b = { x: 1 }; // new object
const c = "hi" + name; // new string
const d = function () {}; // new function object
const e = a.map((x) => x * 2); // new array AND new functionIn tight loops these add up. Allocating a fresh object on every iteration is fine for a hundred iterations and a problem at a hundred thousand. When profilers complain about allocation pressure, this is what they mean.
How memory is reclaimed
The engine periodically asks "what is still reachable from the roots?" — the global object, the current call stack, and a few internal references. Anything not reachable is unreachable for the rest of the program too, so it can be reclaimed. We go deeper into the algorithm in Garbage Collection; for now the rule is simple:
The flip side of that rule is also where leaks come from: if you accidentally keep a reference alive longer than the value is useful, the engine cannot know — it has to assume you still need it.
Common leak shapes
Most real-world JavaScript leaks fall into a small set of patterns. Recognising them on sight is half the battle.
Forgotten closures
A closure captures the variables it uses from the enclosing scope. If the closure outlives the work it was created for, those captured variables stay alive too.
closure leak
function makeReporter() {
const hugeBuffer = new Array(1_000_000).fill(0);
return function report() {
// never reads hugeBuffer, but closes over it anyway
console.log("ping");
};
}
const report = makeReporter();
// hugeBuffer is unreachable from user code, but `report`
// is still alive, so the engine keeps it just in case.Modern engines are smart enough to drop unreferenced captured variables in many cases — but they often play it safe. If a closure is long-lived, only close over what it actually needs.
Detached DOM nodes
Removing a node from the DOM doesn't free it if your code still holds a reference. The node sits in memory, still pointing at its children, attributes and event listeners.
detached node leak
const cache = new Map();
function open(id) {
const panel = document.createElement("div");
panel.id = id;
document.body.append(panel);
cache.set(id, panel); // <- the leak
}
function close(id) {
const panel = cache.get(id);
panel.remove(); // removed from the page...
// ...but cache.get(id) still returns it, so the whole subtree lives on.
}Fix it by clearing the cache entry (cache.delete(id)) when you remove the node, or use a WeakMap keyed by the node itself.
Event listeners that were never removed
addEventListener keeps a reference from the target to your handler. The handler often closes over component state, so the entire component is kept alive even after it scrolled off the page.
listener leak
function mount(el) {
const state = { count: 0, history: [] };
function onClick() {
state.count++;
state.history.push(Date.now());
}
el.addEventListener("click", onClick);
// No matching removeEventListener anywhere — state survives forever.
}Use AbortController to cancel listeners as a group:
cancellable listeners
function mount(el) {
const ac = new AbortController();
el.addEventListener("click", onClick, { signal: ac.signal });
return () => ac.abort(); // call on unmount; removes every listener.
}Timers
A live setInterval keeps its callback — and everything the callback closes over — alive forever.
timer leak
function startPolling(user) {
setInterval(() => {
fetch("/heartbeat/" + user.id);
}, 5_000);
// user is now immortal because the interval references it.
}Always keep the handle and call clearInterval when the work is done — on unmount, on logout, on navigation. The same applies to setTimeout chains that schedule themselves.
Global accumulators
A module-level array or map that only ever grows is a textbook leak. It starts as a debugging convenience and quietly becomes the largest object in your heap.
growing-without-bound
const seenEvents = [];
export function track(event) {
seenEvents.push(event); // never trimmed, never sent, never cleared
}Either flush the buffer (send it, write it, drop it), cap its size with a ring buffer, or use a WeakMap if the entries are tied to objects that come and go.
Weak references, briefly
WeakMap, WeakSet and WeakRef let you point at an object without keeping it alive. They are made for the cache-and-listener cases above:
WeakMap-backed cache
const meta = new WeakMap();
function attach(node, info) {
meta.set(node, info); // entry vanishes automatically when node is GC'd
}How to spot leaks
Open Chrome DevTools → Memory → Heap snapshot. Take one, do the suspected leaky action, take another, then diff. Growing object counts that should be steady are suspects.
Use Detached elements in the Performance tab to find DOM nodes the page no longer shows but JS still references.
Watch the JS heap size chart in the Performance Monitor while you exercise the page. A sawtooth that rises overall is a leak; a steady sawtooth is healthy GC.
For Node, run with
--inspectand use the same Memory tab in DevTools, ornode --heapsnapshot-signal=SIGUSR2pluskill -USR2.