This article is published in English.
Hunting JavaScript Memory Leaks: Reachability, Retainers and Cleanup
Learn why garbage-collected JavaScript still leaks, which everyday patterns retain memory, and how to find the culprit with heap snapshots and retainer chains.
A web app that feels instant at 9 a.m. and sluggish by 5 p.m. is one of the most common symptoms of a memory leak: clicks respond a beat late, scrolling loses its smoothness, animations stutter, the tab climbs past a gigabyte of RAM, and a page reload makes everything fine again. Leaks like this throw no exceptions, break no tests and sail through CI, because they only show up once someone keeps the app open for hours. This guide explains why a garbage-collected language still leaks, walks through the patterns that cause most real-world leaks, and gives you a repeatable Chrome DevTools workflow for finding the exact reference that keeps memory alive.
Garbage collection frees what is unreachable, not what is unused
Because JavaScript never asks you to call malloc() or free(), it is tempting to believe the runtime takes care of memory completely. That belief is only half right. The collector does not reclaim an object when your code is done with it; it reclaims an object when nothing can reach it anymore. If some forgotten reference still points at an object, the engine has no way to tell that the object is dead weight. From the runtime's point of view, anything reachable might still be needed.
That gap between "no longer used" and "no longer reachable" is where some of the hardest performance bugs in modern web development live.
A scenario: the dashboard that slowed down every afternoon
Picture an operations dashboard that staff keep open for an entire shift. In QA it is fast: quick initial load, efficient API calls, strong Lighthouse scores. Then production feedback arrives. After five or six hours, switching tabs lags, charts redraw slowly, and even a simple modal takes a noticeable moment to open.
A typical first reaction is to blame the backend. The team tunes database queries, confirms API responses stay under 100 milliseconds, and sees low CPU usage on the servers. None of it explains a slowdown that grows over the course of the day.
The breakthrough comes from Chrome's Task Manager. The tab started the morning at roughly 150 MB and reached close to 1.4 GB by late afternoon. Every navigation, every dialog, every widget refresh left a little memory behind. Each allocation was tiny; thousands of them were not. Rendering, network latency and raw execution speed were all fine. The application simply never released objects it had stopped needing.
How engines decide what stays alive
Creating an object in JavaScript requires no ceremony at all:
const user = {
id: 101,
name: "Emma"
};
When nothing refers to user anymore, the engine is free to reclaim it. The interesting part is how it decides that. V8 (Chrome and Node.js), SpiderMonkey (Firefox) and JavaScriptCore (Safari) all track a graph of objects linked by references. Roots such as the global object sit at the top, and everything your app builds hangs off them: the app instance, the router, the store, component trees and any global variables.
A collection cycle starts at those roots and follows every reference it can. Whatever it reaches survives. Whatever it cannot reach becomes eligible for cleanup. The key word is eligible, and the key property is unreachable. An object is not collected because it is old, or unused, or forgotten. It is collected only because no path of references leads to it.
Suppose you load a large list of records:
const employees = fetchEmployees();
Some time later the UI no longer shows that data, but another object still points at it:
cache.employees = employees;
Even if nothing ever reads cache.employees again, the collector cannot assume that. The reference exists, so the whole array stays in memory. The engine is behaving exactly as designed; the leak comes from application code.
Thinking in references instead of objects
Leaks become far easier to reason about once you stop asking about objects and start asking about the references that point to them. Take a function that builds an object and returns it:
function createUser() {
const user = {
name: "Alice"
};
return user;
}
const employee = createUser();
After it runs, a single reference connects the variable to the object:
employee
│
▼
{ name: "Alice" }
If you then clear that reference, the object has no incoming edges and the next collection can remove it:
employee = null;
One correction if you try this yourself: employee was declared with const in the earlier snippet, so reassigning it throws a TypeError. Declare it with let when you intend to drop the reference later. The point stands either way: removing the last reference is what makes the object collectable.
Now change the example slightly so that the function stores what it creates in a module-level array:
const users = [];
function createUser() {
const user = {
name: "Alice"
}; users.push(user);
}
Once createUser() returns, every user object is still referenced by the users array, and the array itself is reachable from the top level:
Window
│
▼
users
│
├── User 1
├── User 2
├── User 3
└── User 4
For as long as users is reachable, so is every element in it. This is why leaks usually grow gradually: no single object is large, but thousands of small ones pile up over hours or days.
Leaks accumulate one interaction at a time
The phrase "memory leak" tends to conjure one enormous object eating hundreds of megabytes. In practice the pattern is almost always small and repetitive.
Imagine that closing a settings dialog leaves roughly 20 KB behind. That is negligible on its own. A power user who opens and closes that dialog 500 times in a workday has already leaked around 10 MB. Add five components with similar small leaks per interaction, an eight-hour session, several tabs open at once and live updates arriving every few seconds, and those negligible amounts turn into hundreds of megabytes.
That arithmetic also explains why developers rarely notice. During development you reload the page every few minutes, which wipes the slate clean. Users do not reload; they keep working.
Why single-page applications feel it most
Classic multi-page sites had an accidental safety net: each navigation loaded a fresh document and discarded the entire JavaScript heap, leaked objects included.
Single-page apps built with React, Angular, Vue, Svelte or similar frameworks can run for hours without a full reload. That is great for user experience and exactly why memory discipline matters. Each route change, modal, toast, WebSocket message and chart refresh creates objects. If they are not released properly, they live as long as the page does.
There is an irony here: the better the experience, the longer people stay, and the more opportunities small leaks have to add up.
The collector is sophisticated, not clairvoyant
Modern engines use incremental and generational collection, concurrent marking, compaction and idle-time collection. These techniques make memory management fast and unobtrusive, but they cannot fix logic errors.
Think of lending someone a book and never asking for it back. They cannot know you have forgotten about it, so as far as they are concerned you still want it returned one day. References work the same way. While your application holds a reference, the engine assumes the object matters, whether or not your code will ever touch it again. It cannot read intent; it only follows edges in the graph.
That shift changes how you debug. Instead of wondering why the collector is not freeing memory, you ask a more productive question: what is still holding a reference to this object? That is nearly always where the bug is.
The patterns behind most real-world leaks
The collector is not broken; leaks happen because code keeps references it should have dropped. Those references rarely look suspicious. They come from ordinary, reasonable-looking code rather than exotic algorithms or browser bugs. The following patterns cover the leaks you are most likely to meet in production.
Event listeners that are never removed
Listeners are among the most frequent culprits, especially in SPAs. The setup is usually innocent: grab an element and attach a handler.
const button = document.getElementById("save");
button.addEventListener("click", saveDocument);
Later the user navigates away and the button disappears from the page. Removing an element from the DOM does not, by itself, sever every JavaScript reference involved. If the handler is still registered and something else keeps the element or the handler reachable, both the listener and whatever it closes over stay in memory. The leak is most severe with listeners attached to long-lived targets such as window or document, because those targets never go away. The remedy is to unregister the handler explicitly:
button.removeEventListener("click", saveDocument);
In React, Angular or Vue, do this in the unmount or destroy phase of the component lifecycle. A useful habit is to treat every addEventListener() call as a commitment: when you add one, know when and where it gets removed. Passing an AbortController signal to several listeners and aborting it once on teardown is a convenient way to honor that commitment in bulk.
Timers that outlive their screen
Timers leak just as quietly. A dashboard that polls for fresh data every five seconds might look like this:
const timer = setInterval(() => {
loadLatestData();
}, 5000);
If the user leaves the page and the interval is never cleared, the callback keeps firing in the background. It keeps its closure alive, and with it any functions, variables or even entire component instances that closure references. A few forgotten intervals can hold far more memory than you would expect. Clear them when their owner goes away:
clearInterval(timer);
The same discipline applies to setTimeout() (via clearTimeout()) and requestAnimationFrame() (via cancelAnimationFrame()).
Detached DOM nodes
A detached node is an element that is no longer part of the document but is still referenced from JavaScript. Grabbing a modal and removing it is a typical way to create one:
const modal = document.getElementById("modal");
modal.remove();
It looks gone, yet if any variable, array, closure or state object still points at that element, it cannot be collected, and neither can its children. Apps that dynamically create modals, tooltips, dropdowns or notification panels are especially prone to this. Each node is small, but after hundreds of interactions the detached subtrees can occupy a surprising amount of memory.
Closures that capture more than they need
Closures are one of JavaScript's most powerful features, and they also make it easy to retain memory by accident. Consider a factory that allocates a large array before returning a function:
function createLogger() {
const largeData = new Array(100000).fill("data");
return function () {
console.log("Logging...");
};
}
The returned function never touches largeData. Whether that array stays alive depends on how the engine represents the enclosing scope. In practice, V8 only keeps variables that some closure in that scope actually references, so this exact snippet usually does not retain the array. The risk appears when a second closure created in the same scope does use largeData: the closures share one context object, so the long-lived logger ends up keeping the big array alive too. Using eval inside the scope also forces the engine to keep everything.
None of this makes closures bad; modern JavaScript depends on them. The lesson is to be deliberate about what a long-lived function can see. If it only needs one value, pass or copy that value instead of closing over an entire object or dataset. Small adjustments to scope can noticeably reduce memory usage.
Caches without an eviction policy
Caching saves repeated work, but a cache that only grows is just a slow leak with good intentions. Here is a minimal memoizing lookup:
const cache = {};
function getUser(id) {
if (!cache[id]) {
cache[id] = fetchUser(id);
} return cache[id];
}
It performs well at first. Six months into production, it may hold hundreds of thousands of entries nobody will ask for again. Instead of letting a cache grow without bound, consider:
- A maximum size
- Time-based expiry for stale entries
- An LRU (least recently used) eviction strategy
- A
WeakMapwhen the cache is keyed by objects whose lifetime should decide the entry's lifetime
Note that WeakMap only accepts objects (or non-registered symbols) as keys, so it does not replace an LRU for caches keyed by numeric IDs like the one above. If you want a refresher on how weak references behave, see our overview of Symbols, WeakMaps, Proxies and generators. A cache with no removal strategy is not really a cache; it is permanent storage.
Globals that live forever
Anything attached to global scope lives as long as the application. That is convenient and risky in equal measure. A module-level collection like this one:
let allUsers = [];
keeps growing every time new data is appended:
allUsers.push(...newUsers);
Unless some code explicitly trims it, the array only gets bigger. Over months of development, large global objects tend to become dumping grounds where data accumulates. When you investigate a performance problem, global state is one of the first places worth checking.
WebSockets and other long-lived connections
Real-time features often rely on WebSockets, and opening one takes a single line:
const socket = new WebSocket(url);
The mistake is failing to close it. An open socket keeps receiving messages, firing callbacks and holding on to application state after the user has moved elsewhere. Close connections when the feature that owns them goes away:
socket.close();
The same rule covers observables, streams, custom event emitters and any other subscription that can outlive its consumer.
The common thread
These examples look different on the surface, but they share one root cause: something keeps a reference to an object that should have become unreachable. That something is usually one of these:
- A registered listener
- A pending interval or timeout
- A closure scope
- An ever-growing cache
- A module-level or global variable
- An open socket or other subscription
Once you think in references rather than objects, leak hunting becomes much more intuitive. Instead of asking why memory keeps rising, ask what is still holding on to the object. That question tends to lead straight to the leak.
Finding the leak before your users do
Knowing the causes is useful, but in a real codebase the hard question is simply where the leak is. A large app may have thousands of components and hundreds of listeners, with objects being created every second. Guessing rarely works. Browser tooling is excellent; what makes the difference is using it in a consistent, disciplined order rather than learning every feature of DevTools.
Confirm that you are actually leaking
Rising memory is not automatically a leak. Engines allocate as the app works and reclaim memory during collection, so a healthy app produces a sawtooth pattern:
Memory
^
| /\ /\ /\
| / \ / \ / \
|______/____\__/____\___/____\____ Time
Usage climbs during activity and falls back after each collection. A leaking app looks different:
Memory
^
| /\ /\
| / \ / \
| / \ / \
|_______/______\__/______\________
| /
| /
| /
|___________/________________ Time
The small dips show that the collector is running, but each trough sits higher than the previous one. Memory never returns to its earlier baseline, which is the first real sign that objects are being retained. Before drawing conclusions, trigger a collection manually (the trash-can icon in the Memory and Performance panels), since a baseline that only looks high because collection has not run yet is not a leak.
Step 1: open the Memory panel
Chrome offers several memory tools, but you do not need all of them at once. Open DevTools and switch to the Memory panel. Depending on your Chrome version, you will see profiling types such as a heap snapshot, allocation instrumentation on a timeline, and allocation sampling; the exact names and options change between releases, so check the current DevTools documentation if yours differ.
For most investigations a heap snapshot is the right starting point, because it shows what currently occupies memory.
Step 2: record a baseline
Before exercising the suspect feature, take a snapshot of the app's initial state, a photograph of the heap. Then perform the interaction you suspect, repeatedly. For example:
- Open and close a modal ten times
- Navigate back and forth between pages
- Run a file upload
- Apply filters to a big data table
- Flip between dashboard tabs over and over
When you are done, take a second snapshot. Now you have two states to compare.
Step 3: compare the snapshots
This is where the investigation really starts. If cleanup works, temporary objects created during the interaction should vanish after collection. If it does not, certain object types keep increasing. Typical suspects include:
- Detached DOM elements
- Large arrays
- Event listeners
- Your own application classes
- Framework components that should have been destroyed
You do not need to understand every entry in the heap. Use the comparison view and look for object types whose count rises by a consistent amount each time you repeat the same action. Consistency is usually the strongest clue.
Detached DOM nodes are the easiest evidence to spot
Detached nodes are one of the simplest leaks to recognize. Open and close a modal twenty times; after each close, that modal should be gone. If the snapshot still contains twenty modal elements, something is holding on to them. You can type "Detached" into the snapshot's class filter to list them quickly.
The DOM itself is rarely the real problem. The actual cause usually sits elsewhere:
- A handler still registered on the element or on a long-lived target
- An interval or timeout whose callback mentions the node
- A closure that captured the element
- A store, component field or array that saved a pointer to it
Treat the detached node as a symptom: proof that some other reference prevented cleanup.
Follow the retainer chain
Once you find an object that clearly should not exist anymore, the next question is who is keeping it alive. The Retainers section of a heap snapshot answers exactly that. Select the object and Chrome shows the chain of references connecting it back to a GC root. Conceptually, it might read like this:
Window
│
Application
│
UserService
│
cachedUsers
│
User Object
The investigation now becomes straightforward. Rather than puzzling over why a user object persists, you can see it is referenced by a cachedUsers collection inside UserService. Locating the retained object is helpful; identifying what retains it is what actually fixes the bug.
Watch live counters with Performance Monitor
Snapshots are ideal for detailed analysis, but they are not the only tool. Chrome's Performance Monitor shows live metrics that include:
- JS heap size
- Number of DOM nodes
- Number of JS event listeners
- Documents and frames
If DOM nodes or listeners keep climbing while you repeat the same action, cleanup is not happening. The advantage is speed: you do not need to wait until the app feels slow, and suspicious trends often become visible within a few minutes.
Isolate one small, repeatable scenario
A frequent mistake is trying to investigate the whole application at once. Narrow it to a single interaction instead:
- Show a single modal, dismiss it, and do that twenty times in a row
- Alternatively, bounce between the same two routes fifty times
Tight scenarios like these are far easier to quantify. When one repeated action produces growth on every iteration, you have already narrowed the search dramatically, and the responsible code is usually easy to find from there.
Test long sessions deliberately
Developers tend to exercise an app for ten or fifteen minutes and move on. Real users of an internal dashboard, trading platform, monitoring tool or support portal may keep it open all day. Include long-running sessions in your memory testing: leave the app open, interact with it periodically and watch how memory evolves. Many leaks only become visible after hundreds or thousands of interactions.
A debugging workflow that avoids guesswork
Jumping between profilers wastes time. A fixed sequence tends to work better:
- Confirm that memory keeps rising across collections.
- Reproduce the growth with one repeatable action.
- Capture heap snapshots before and after.
- Compare object counts between them.
- Identify the objects that are being retained.
- Trace the retainer chain to see who holds the reference.
- Fix the code and rerun the exact same test.
This takes the guessing out of the process. Instead of assuming a particular component is guilty, you let the evidence point you to it.
Preventing leaks before they reach production
Diagnosis is only half the story; the cheaper win is not introducing leaks in the first place. Most leaks are not the product of developers misunderstanding JavaScript. They happen because modern apps are long-lived, highly interactive and constantly allocating, and in that environment it is easy to forget that everything you create eventually needs to be torn down. Teams that rarely fight memory issues are not necessarily writing smarter code; they have habits that make leaks unlikely.
Give every resource an explicit end of life
Whenever code creates something long-lived, ask one question: when will this be destroyed? That applies to far more than raw memory:
- Listeners on elements,
windowordocument - Intervals, timeouts and animation frames
- Sockets and other network connections
- Observables, stores and other subscriptions
- Saved references to DOM elements
- Cached responses and computed values
- Web workers and similar background tasks
Setting these up is usually easy; cleaning them up is where applications fall short. A simple rule captures it: if your code has a start, it also needs a stop. That mindset alone prevents a surprising share of leaks.
Make components clean up after themselves
Component-based frameworks encourage self-contained units, and self-contained should include teardown. A component that starts a timer stops it when it unmounts. A component that registers listeners removes them. Nothing a component started should keep running once it is off screen.
Think of checking out of a hotel room: you do not walk away with the lights on, the TV playing and the tap running. A well-behaved component leaves things as it found them. In React, that means returning a cleanup function from every useEffect that subscribes, schedules or connects.
Design caches around removal, not just insertion
Caches start with good intentions, avoiding repeated API calls or expensive computations. Months later they can hold thousands of objects nobody has looked at in weeks. When you design one, think about how entries leave as carefully as how they arrive:
- How long should an entry stay in memory?
- What is the maximum size?
- Should entries expire automatically?
- Can rarely used entries be evicted?
If those questions have no answers, the cache will almost certainly grow over time.
Keep only the data you actually need
Another frequent issue is holding entire objects when only a small part is used. If you fetch a large profile just to show a username, there is no reason to keep the full response indefinitely; store the fields the UI needs. Smaller objects consume less memory, are easier to reason about and are less likely to be retained by accident. Sometimes the fix is not more code but less stored data.
Take small leaks seriously
It is tempting to wave off a leak of a few kilobytes. The problem is that users rarely do anything just once. An all-day dashboard, an internal admin tool shared by hundreds of staff, or a monitoring wall that nobody ever reloads runs the same code paths thousands of times. A leak that is barely measurable today can become a real incident after weeks of normal use.
Add memory checks to everyday development
Performance work usually focuses on load time and API latency, but memory deserves a place too. While building a feature, spend a few extra minutes checking:
- Does memory return to its baseline after using the feature?
- Is the listener count rising unexpectedly?
- Do DOM nodes disappear once components are removed?
- Does repeating the same action steadily increase memory?
These checks are cheap and can save hours of debugging later.
A code review checklist
Before merging, run through a short mental list:
- Is every event listener that was added also removed?
- Are timers cleared once they are no longer needed?
- Are subscriptions disposed of properly?
- Could this cache grow without limit?
- Are references held longer than necessary?
- Does the component clean up everything it creates?
You do not need to apply this to every line, but making it part of review sharply reduces the odds of shipping a leak.
Key takeaways
- Leaks rarely crash anything on day one; they build up quietly and hurt your most engaged users first, which is what makes them dangerous.
- They are also predictable: an object stays alive only because something still references it, so every leak traces back to a reference that should have been dropped.
- When an app slows down over hours, resist blaming the browser or the engine. Take a heap snapshot, find the retained objects and follow the retainer chain.
- The usual answer is mundane: a forgotten listener, an uncleared timer, an unbounded cache or a component that never finished cleaning up.
- Fast JavaScript is not only about execution speed; it is about managing the lifecycle of what you allocate, so the app stays responsive whether someone uses it for five minutes or a full workday.