Finding and Fixing Memory Leaks in JavaScript
A JavaScript memory leak happens when a reference outlives its usefulness and the garbage collector can't reclaim it. Common causes and how to find them.
A JavaScript memory leak happens when an object is no longer needed by your application but something still holds a reference to it, so the garbage collector can never reclaim its memory. JavaScript doesn’t require manual memory management the way languages like C do, but automatic garbage collection only frees memory that’s genuinely unreachable — it can’t tell the difference between a reference you forgot to remove and one you still need.
How garbage collection decides what to keep
Modern JavaScript engines use a mark-and-sweep collector: starting from a set of root references (global variables, currently executing function scopes), the engine walks every reachable object and marks it as live. Anything not marked after that walk is considered garbage and gets freed. The critical detail is that “reachable” is a purely structural question — if any live reference chain leads to an object, no matter how obscure or unintentional, that object survives, whether or not your application still has any real use for it.
This is different from reference counting (used in some other environments), where an object is freed the moment its reference count hits zero. Mark-and-sweep avoids reference-counting’s classic circular-reference problem, but it means a leak in JavaScript is never about a broken collector — it’s always about an unintended reference somewhere in a live chain.
Common sources of leaks
Detached DOM nodes. Removing an element from the document doesn’t free it if JavaScript code still holds a reference — say, in an array cache or an event handler’s closure. The node is gone from the visible page but stays alive in memory, along with everything it references.
Forgotten event listeners. Attaching a listener to a long-lived object (like window or document) from inside a component that gets created and destroyed repeatedly leaks one listener — and everything its closure captured — per creation, if the listener is never removed. addEventListener without a matching removeEventListener on cleanup is one of the most common leak sources in single-page applications.
Timers and intervals. A setInterval that’s never cleared with clearInterval keeps running — and keeps its closure’s captured variables alive — even after the component or context that started it is gone.
Accidental global variables. Assigning to an undeclared variable (leaked = data without let, const, or var) attaches it to the global object, which is a root reference that’s never collected for the lifetime of the page.
Uncleared caches. A plain object or array used as a cache with no eviction policy grows forever, since every entry added is a reference the collector considers live indefinitely. An LRU cache exists specifically to bound this by evicting the least recently used entries once the cache hits a size limit.
Where WeakMap and WeakRef help
WeakMap and WeakSet hold weak references to their keys — a reference that doesn’t, by itself, keep an object alive. If the only remaining reference to an object is as a WeakMap key, the garbage collector is free to reclaim it, and the WeakMap entry disappears along with it. This makes WeakMap a natural fit for caches and metadata associated with DOM nodes or objects whose lifetime you don’t control: you get the convenience of a lookup table without it becoming the thing keeping otherwise-unused objects alive. See WeakMap and WeakRef explained for the full mechanics and the narrower cases where WeakRef on its own is the right tool instead.
Leaks in single-page applications
Client-side routing makes leaks easier to accumulate than in a traditional multi-page site, because a single-page application never gets the clean slate a full page navigation provides — every “page” is really the same document being mutated in place, so anything a component forgot to clean up on unmount survives every subsequent route change for the rest of the session. A component that adds a scroll listener, subscribes to a WebSocket, or starts an interval on mount needs to symmetrically undo each of those on unmount; frameworks that provide an explicit lifecycle or cleanup hook for this reason are trying to make that symmetry hard to forget, but the underlying discipline — every subscription needs a matching unsubscription — applies regardless of framework.
Leaks on the server
Memory leaks aren’t only a browser concern. A long-running Node.js server process accumulates leaked memory the same way a browser tab does, except a server process often runs for weeks between restarts rather than being closed when a user shuts a browser tab — so a slow leak that would be imperceptible in a browser session can gradually degrade a server’s memory footprint until it’s restarted or crashes under memory pressure. Common server-side culprits mirror the browser list: module-level caches that grow without bound, event emitters accumulating listeners across requests, and closures inadvertently captured inside long-lived request handlers. Server monitoring that tracks heap usage over time — rather than only at a single point — is the server-side equivalent of the DevTools heap snapshot comparison described below, and it’s often the first signal that a leak exists before anyone has identified its cause.
Finding a leak with devtools
- Take a heap snapshot in Chrome DevTools’ Memory panel, perform the suspected leaking action several times (open and close a modal, navigate between routes), then take a second snapshot.
- Use the “Comparison” view between the two snapshots to see which object types grew in count — a steadily increasing count of detached DOM nodes or listener objects across repeated snapshots is the clearest signal of a leak rather than normal, bounded memory use.
- Inspect the retainer path for a suspect object — DevTools shows exactly which reference chain is keeping it alive, which usually points straight at the forgotten listener, timer, or cache entry responsible.
The takeaway
A JavaScript memory leak isn’t a garbage collector failing to do its job — it’s an application holding a reference longer than it needs to, whether through a detached DOM node, an unremoved event listener, an uncleared timer, or an unbounded cache. Clean up what you attach: remove listeners and clear timers when a component unmounts, bound your caches, and reach for WeakMap when you need to associate data with an object without controlling — or wanting to control — that object’s lifetime.
Keep reading
Takina · · 4 min read requestIdleCallback Explained
requestIdleCallback runs low-priority JavaScript when the browser is idle, without blocking rendering, input, or the main thread.
Takina · · 4 min read Dynamic import() in JavaScript: Code-Splitting Explained
JavaScript's dynamic import() loads a module on demand and returns a promise, letting you split bundles and defer code until it's actually needed.
Takina · · 4 min read Intersection Observer API Explained
The Intersection Observer API tells you when an element enters or leaves the viewport, without scroll-event polling. How it works and where to use it.