Skip to main content

Memory leaks in JS

A memory leak is a situation where a program keeps references to objects it no longer needs, so the garbage collector cannot free them. In other words, an object is no longer used but stays in memory because of forgotten references, gradually eating up the heap.

Theory

TL;DR

  • A leak is holding unnecessary objects in memory through references nobody cleared.
  • The JS garbage collector only removes unreachable objects: if even one reference exists, the object is alive.
  • Main causes: implicit globals, uncleared timers, listeners that were never removed, closures over large data, unbounded caches, references to removed DOM nodes.
  • Symptoms: the heap grows from snapshot to snapshot, detached DOM nodes appear, the interface slows down over time.
  • Diagnosis: Chrome DevTools, the Memory tab and a heap snapshot; in Node.js, --inspect, heapdump, process.memoryUsage().
  • Prevention: clean up timers and listeners, bound your caches, use WeakMap and WeakSet with their weak references.

Quick example

javascript
const btn = document.getElementById('btn'); function handleClick() { console.log('clicked'); } btn.addEventListener('click', handleClick); btn.remove(); // the element is out of the DOM, but the listener stayed // handleClick and btn itself remain in memory: this is a detached DOM node

The correct version: remove the listener with btn.removeEventListener('click', handleClick) before dropping the node, and clear any outer variables that point to the element.

How the garbage collector works and where a leak comes from

JavaScript manages memory automatically. The garbage collector periodically looks for reachable objects, that is, objects you can walk to through a chain of references from the roots (the global object, the call stack, live closures).

  • Everything with no references to it (unreachable) is removed.
  • Everything with at least one reference stays in memory.

That is exactly where the problem comes from: the object is logically no longer needed, but some reference to it survives, so the collector considers it alive and does not free it. Importantly, the engine cannot tell a needed reference from a forgotten one, so a leak is always a bug in the code, never in the collector.

Typical causes of leaks

CauseExampleWhat happens
Global variableswindow.obj = { big: 'data' }they live until the tab is closed
Uncleared timers and intervalssetInterval(() => {...}, 1000) without clearIntervalthe callback holds its closure and is never released
Event listenerselement.addEventListener('click', handler) without removeEventListenerthe element is gone, the handler stays in memory
Closures over large datafunctions capturing a scope with large objectsthe collector cannot free that scope
Caches and data structuresa Map that grows without evicting old keysentries are never removed automatically
DOM references after removing a nodeconst el = document.getElementById('app'); el.remove();the node is out of the DOM, but el still holds it

An implicit global:

javascript
function createLeak() { leak = []; // without let / const the variable lands on window for (let i = 0; i < 1000000; i++) leak.push(i); } createLeak(); // leak lives until the tab is closed

A timer nobody stops:

javascript
function start() { const data = new Array(1e6).fill('x'); setInterval(() => console.log(data.length), 1000); } start(); // data is never cleared, the callback closure holds it

An unbounded cache:

javascript
const cache = new Map(); function heavyCalc(key) { if (cache.has(key)) return cache.get(key); const result = Array(1e5).fill(key); cache.set(key, result); // no entry is ever removed return result; }

How to detect a memory leak

In the browser (Chrome DevTools):

  1. Open the Memory tab and pick Heap snapshot.
  2. Take a snapshot.
  3. Repeat the action you suspect (open and close a modal, navigate between pages) and take a second snapshot 10 to 30 seconds later.
  4. Compare the snapshots in Comparison mode: if the object count or the number of Detached DOM nodes keeps rising, that is a leak.

The Performance tab with the Memory checkbox enabled is useful too: it shows whether the heap size returns to its baseline after a collection, or whether the line keeps creeping upwards.

In Node.js:

  • node --inspect plus Chrome DevTools for a heap snapshot.
  • Tooling such as clinic.js, node --inspect-brk and the heapdump package.
  • Periodic monitoring with process.memoryUsage() and its heapUsed and rss metrics.

Symptoms you can see without a profiler:

  • The app gradually slows down during a long session.
  • Memory grows and never comes back down after navigation.
  • Interface delays, especially during scrolling and rendering.
  • Frequent and long garbage collection pauses.
  • The tab eventually crashes with an out of memory message.

How to prevent leaks

ProblemSolution
Global variablesAlways declare with let and const, enable strict mode
TimersCall clearTimeout and clearInterval when stopping
Event listenersDetach with removeEventListener on unmount
ClosuresDo not keep large objects in the scope of long lived functions
CachesBound the size and evict old entries, or use WeakMap / WeakSet
DOMWhen removing a node, clear every variable referencing it
SPA applicationsWatch the cleanup in useEffect, onUnmounted and similar hooks

A separate tool is WeakMap and WeakSet. They hold weak references, so they do not block garbage collection:

javascript
const cache = new WeakMap(); function getData(obj) { if (cache.has(obj)) return cache.get(obj); const data = heavyCalculation(obj); cache.set(obj, data); return data; }

If obj is not used anywhere else, the collector will remove it automatically even though it is a key in the WeakMap. The price: such collections are not iterable, have no size, and only objects can be keys.

A short summary:

WhatDescription
Memory leakHolding unneeded objects in memory
CauseReferences that are never released
ConsequenceGrowing memory, lag, a crashing tab
DiagnosisChrome DevTools, the Memory tab and heap snapshots
FixClean up timers, listeners, caches and DOM references
PreventionWeakMap, WeakSet, cleanup hooks

Common mistakes

  1. Believing the collector counts references. Modern engines use reachability (mark and sweep), so cyclic references are not a leak by themselves: two structures pointing at each other but unreachable from outside will be collected.
  2. Removing a listener with an anonymous function. removeEventListener('click', () => {...}) does nothing: you need the exact same function reference you passed to addEventListener.
  3. Forgetting cleanup in components. A useEffect without a cleanup function leaves subscriptions, timers and AbortController instances alive after the component unmounts.
  4. Confusing high memory usage with a leak. An app may simply hold a large legitimate state; a leak is when memory grows and does not come back after a collection.
  5. Treating WeakMap as a universal fix. Only the key is weak; if the value points back at the key or at something large and reachable, nothing is freed.
  6. Drawing conclusions from a single heap snapshot. A leak is only visible over time: you need at least two snapshots taken after the same scenario and a forced collection.
  7. Closing over an entire large object for the sake of one field. Copying out the value you need (const { id } = user) lets the rest of the structure be collected.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.