Frontend Memory Leaks — Detection, Diagnosis and Prevention

A practical system-design guide to understanding browser memory, finding retained objects, preventing lifecycle leaks, and keeping long-running web applications stable.

Table of Contents

  1. What a Memory Leak Is
  2. How Browser Memory Works
  3. Memory Leak vs High Memory Usage
  4. Common Frontend Leak Sources
  5. Framework Lifecycle Patterns
  6. A Repeatable Detection Workflow
  7. Chrome DevTools Deep Dive
  8. Automated Memory Regression Testing
  9. Production Monitoring
  10. Prevention by Design
  11. System Design for Long-Running Apps
  12. End-to-End Incident Case Study
  13. Debugging Checklist
  14. Interview Questions and Answers

1. What a Memory Leak Is

A memory leak occurs when an application no longer needs an object but a reachable reference still prevents the garbage collector from reclaiming it.

GC root | +-- window | +-- event listener | +-- callback closure | +-- old component state | +-- large response data 

The old component is no longer visible, but the listener keeps its callback reachable. The callback keeps its closure reachable, and the closure keeps the component state and response data reachable.

In a long-running single-page application, repeated leaks usually produce a staircase:

Memory ^ | ________ | ______/ | ______/ | ______/ |___/________________________________> Time open/close the same screen repeatedly 

Garbage collection still happens, but the post-GC baseline rises after each cycle.

User-visible symptoms

  • Navigation becomes slower after the app has been open for a long time.
  • Typing, scrolling, and animation become janky.
  • The number of DOM nodes or event listeners keeps increasing.
  • Background CPU usage continues after leaving a screen.
  • Mobile browsers reload or terminate the tab.
  • The browser reports an out-of-memory crash.

Back to Top

2. How Browser Memory Works

JavaScript engines allocate most objects on a managed heap. The garbage collector starts from known roots and marks everything reachable from them.

Typical roots include:

  • window, document, and active DOM trees
  • Current call stacks
  • Module-level and global variables
  • Active timers and animation callbacks
  • Registered event listeners
  • Pending asynchronous callbacks
  • Open observers, workers, sockets, and subscriptions

Objects that cannot be reached from any root are eligible for collection.

Reachable from a root? Yes -> keep the object No -> reclaim the object 

The collector does not understand business intent. It cannot know that an event listener belongs to a screen the user already closed. If the reference is valid, the object remains alive.

Retained size vs shallow size

Measurement Meaning
Shallow size Memory used by the object itself
Retained size Memory that would become collectible if this object were removed

A small listener function can have a tiny shallow size but a huge retained size when its closure captures a large object graph.

Garbage collection is not immediate

Memory can remain high temporarily because the engine decides when to collect. A single measurement is therefore weak evidence. A reliable test repeats the same action, forces or waits for GC in a controlled environment, and compares the post-GC baseline.

JavaScript heap vs total browser memory

The JavaScript heap is only one part of a tab's memory footprint.

Memory category Examples Typical tools
JavaScript heap Objects, arrays, closures, application stores Heap snapshots, allocation profiler
DOM-related memory Live and detached nodes, layout structures, event targets Heap snapshots, DOM node counters
Browser/native memory Decoded images, GPU textures, media buffers, network buffers Chrome Task Manager, browser tracing
Worker/process memory Web Workers, service workers, renderer processes Chrome Task Manager, worker-specific profiling

A stable JavaScript heap does not prove that total memory is stable. An image gallery can release its JavaScript references while decoded images or GPU resources still create memory pressure. Use the measurement that matches the suspected resource.

Back to Top

3. Memory Leak vs High Memory Usage

High memory usage is not automatically a leak.

Situation Leak? Explanation
A photo editor intentionally keeps the active image in memory No The data is still needed
A cache grows to 100 entries and then evicts old entries No Memory is bounded
A cache grows forever as users navigate Yes Unbounded retention
A fetch callback retains a component until the request settles Usually temporary It becomes a leak risk when requests never settle or accumulate
Opening and closing a modal adds another listener every time Yes Old modal closures remain reachable
Memory rises during work and returns near baseline after GC No Normal allocation behavior

The key test

Ask:

After the feature returns to its original state, should these objects still exist?

If the answer is no but a retaining path still connects them to a GC root, the application has a leak.

A rising post-GC baseline is a strong signal, not proof by itself. Establish the root cause by combining:

  1. Repeatable growth after the same lifecycle action
  2. Heap or resource comparisons that identify what accumulates
  3. A retaining path or owner keeping objects alive
  4. Product evidence that those objects are no longer needed

Back to Top

4. Common Frontend Leak Sources

4.1 Forgotten event listeners

Why it leaks: Global targets such as window and document live for the entire page. An event target stores a strong reference to every registered callback. The callback's closure can retain component state, API responses, and DOM nodes after the component disappears.

window -> resize listener -> callback closure -> panelState -> old panel data and DOM 

Leaking code:

function mountPanel(panelState) { window.addEventListener('resize', () => { panelState.recalculate(); }); } // Calling mountPanel() for every route visit adds another listener. // Nothing removes the old callback. 

How to fix it: Give the listener the same lifecycle as the panel. An AbortSignal is useful because the caller does not need to keep the callback reference required by removeEventListener.

function mountPanel(panelState) { const controller = new AbortController(); window.addEventListener( 'resize', () => panelState.recalculate(), { signal: controller.signal } ); return () => controller.abort(); } const destroyPanel = mountPanel(panelState); // When the panel unmounts: destroyPanel(); 

4.2 Timers and animation frames

Why it leaks: The scheduler retains the timer or animation callback while it remains active. That callback retains everything in its closure. An interval or recursive animation loop can therefore keep an entire destroyed screen reachable and continue consuming CPU.

timer scheduler -> interval callback -> loadUpdates closure -> old screen store 

Leaking code:

function mountActivityFeed(feedStore) { setInterval(() => { feedStore.refresh(); }, 5_000); requestAnimationFrame(function render() { feedStore.renderProgress(); requestAnimationFrame(render); }); } 

How to fix it: Store every timer/frame ID and cancel it when the owner is destroyed. For a recursive animation loop, guard against scheduling the next frame after disposal.

function startPolling(loadUpdates) { const intervalId = setInterval(loadUpdates, 5_000); let disposed = false; let frameId; function renderFrame() { if (disposed) return; renderProgress(); frameId = requestAnimationFrame(renderFrame); } frameId = requestAnimationFrame(renderFrame); return () => { disposed = true; clearInterval(intervalId); cancelAnimationFrame(frameId); }; } 

Recursive setTimeout, requestIdleCallback, and library schedulers require the same ownership discipline.

4.3 Detached DOM nodes

Why it leaks: Removing an element from the document only removes it from the live DOM tree. If JavaScript still references that element, the garbage collector must keep the element, all of its children, attached listeners, and related closures.

Leaking code:

const retainedNodes = []; function removeCard(card) { card.remove(); retainedNodes.push(card); } // retainedNodes -> detached card -> child nodes/listeners/state 

How to fix it: Do not store removed nodes in long-lived collections. If a registry is necessary, delete the entry during teardown. Use WeakMap when metadata should follow the node without keeping it alive.

const metadataByCard = new WeakMap(); function registerCard(card, metadata) { metadataByCard.set(card, metadata); } function removeCard(card) { card.remove(); // No long-lived strong reference remains; the WeakMap does not keep card alive. } 

The retaining reference can also hide inside a closure, debugging registry, third-party widget, or stale virtualized-list cache. Use the heap snapshot's Retainers panel rather than assuming remove() releases everything.

4.4 Subscriptions and event buses

Why it leaks: A publisher normally outlives its subscribers. Its subscriber collection holds each callback, and each callback can retain the destroyed component or route store.

Leaking code:

function mountDashboard(eventBus, dashboardStore) { eventBus.subscribe('metrics:update', metrics => { dashboardStore.update(metrics); }); } // eventBus -> subscriber callback -> dashboardStore 

How to fix it: Make subscription return an idempotent unsubscribe function and invoke it during teardown.

function mountDashboard(eventBus, dashboardStore) { return eventBus.subscribe('metrics:update', metrics => { dashboardStore.update(metrics); }); } const unsubscribe = mountDashboard(eventBus, dashboardStore); // When the dashboard unmounts: unsubscribe(); 

Returning an unsubscribe function is safer than requiring callers to reconstruct the original topic and callback. Development diagnostics should expose subscriber counts so tests can assert that they return to baseline.

4.5 Observers

Why it leaks: Observer objects retain their callbacks and can retain observed targets or records. If the observer is owned by a destroyed screen but never disconnected, its callback closure and associated state remain reachable.

  • IntersectionObserver
  • ResizeObserver
  • MutationObserver
  • PerformanceObserver

Leaking code:

function observeChart(element, chartModel) { const observer = new ResizeObserver(() => { chartModel.resize(element.clientWidth); }); observer.observe(element); // observer and chartModel have no teardown path } 

How to fix it: Return cleanup from the setup function. Use unobserve(element) when only one target ends, or disconnect() when the whole observer owner ends.

function observeChart(element, chartModel) { const observer = new ResizeObserver(() => { chartModel.resize(element.clientWidth); }); observer.observe(element); return () => observer.disconnect(); } const stopObserving = observeChart(element, chartModel); stopObserving(); 

4.6 WebSockets, streams, workers, and channels

Why it leaks: These resources are designed to outlive a single task. They retain event handlers, queues, buffers, execution contexts, and sometimes operating-system resources until explicitly closed.

Leaking code:

function mountLiveView(url, viewStore) { const socket = new WebSocket(url); const worker = new Worker('/workers/parser.js'); socket.addEventListener('message', event => { worker.postMessage(event.data); }); worker.addEventListener('message', event => { viewStore.add(event.data); }); // Navigating away does not close either resource. } 

How to fix it: Assign one lifecycle owner and close every resource during teardown.

function mountLiveView(url, viewStore) { const socket = new WebSocket(url); const worker = new Worker('/workers/parser.js'); const channel = new BroadcastChannel('updates'); worker.addEventListener('message', event => { viewStore.add(event.data); }); return () => { socket.close(1000, 'screen disposed'); worker.terminate(); channel.close(); }; } 

Also release readers, stream locks, media tracks, and third-party SDK clients when their scope ends.

4.7 Unbounded collections and caches

Why it leaks: A module-level Map, Set, array, history, or queue is reachable for the lifetime of the application. If entries are never evicted, every visited entity remains reachable even when the UI no longer needs it.

Leaking code:

const responsesByUrl = new Map(); async function cachedFetch(url) { if (!responsesByUrl.has(url)) { responsesByUrl.set(url, await fetch(url).then(response => response.json())); } return responsesByUrl.get(url); } 
module scope -> responsesByUrl -> every URL ever visited -> every response object 

Every cache needs:

  • A maximum size or memory budget
  • An eviction policy such as LRU or TTL
  • Invalidation rules
  • Metrics for entries, bytes, hit rate, and evictions

TTL alone does not guarantee bounded memory. Expired entries can remain allocated until something reads them or a cleanup job removes them. Enforce a maximum entry or byte limit and actively sweep expired entries.

Treat concurrent request deduplication as a separate concern. Without an in-flight request map, two simultaneous misses for the same key can allocate duplicate responses and perform duplicate network work.

How to fix it: Enforce a hard bound and actively remove expired entries. The following example uses insertion order as a simple LRU approximation:

class BoundedCache { #entries = new Map(); constructor(maxEntries, ttlMs) { this.maxEntries = maxEntries; this.ttlMs = ttlMs; } get(key) { const entry = this.#entries.get(key); if (!entry) return undefined; if (entry.expiresAt <= Date.now()) { this.#entries.delete(key); return undefined; } this.#entries.delete(key); this.#entries.set(key, entry); return entry.value; } set(key, value) { this.#entries.delete(key); this.#entries.set(key, { value, expiresAt: Date.now() + this.ttlMs, }); while (this.#entries.size > this.maxEntries) { const oldestKey = this.#entries.keys().next().value; this.#entries.delete(oldestKey); } } clear() { this.#entries.clear(); } } 

For large values, enforce an estimated byte budget rather than only an entry count. Clear route- or workspace-scoped caches when their owner ends.

4.8 Closures retaining large state

Why it leaks: A closure retains referenced variables from its lexical scope. If a long-lived listener, timer, or registry stores that function, one seemingly small callback can retain a large report, store, or component tree.

Leaking code:

function createHandler(report) { return () => console.log(report.title); } 

If the returned handler is placed in a global registry, it retains the complete report, including a potentially large rows array.

How to fix it: Capture only the primitive or small object the callback needs, and remove the callback from its owner when finished.

function createHandler(report) { const { title } = report; return () => console.log(title); } const handler = createHandler(report); shortcutRegistry.add(handler); function destroy() { shortcutRegistry.delete(handler); } 

4.9 Blob URLs and media resources

Why it leaks: URL.createObjectURL() asks the browser to retain the underlying Blob until the URL is revoked. Media streams can keep camera, microphone, decoder, and buffer resources alive even after their UI disappears.

Leaking code:

const objectUrl = URL.createObjectURL(file); preview.src = objectUrl; // Removing preview alone does not revoke objectUrl. preview.remove(); 

How to fix it: Revoke Blob URLs, stop media tracks, pause media, and clear element sources during teardown.

const objectUrl = URL.createObjectURL(file); preview.src = objectUrl; function destroyPreview() { preview.pause?.(); mediaStream?.getTracks().forEach(track => track.stop()); URL.revokeObjectURL(objectUrl); preview.removeAttribute('src'); preview.load?.(); preview.remove(); } 

4.10 Pending asynchronous work

A promise that eventually settles is usually temporary retention rather than a permanent leak. The risk grows when operations are long-lived, repeatedly created, never settle, or are stored in another long-lived collection.

Why it can retain memory: The pending operation stores its continuation callback. That callback closes over the component state. Repeated obsolete requests can therefore retain multiple screen generations until they settle.

Leaking or wasteful code:

function mountProfile(userId, profileStore) { fetch(`/api/users/${userId}`) .then(response => response.json()) .then(user => profileStore.setUser(user)); // No cancellation or stale-result guard when userId changes. } 

How to fix it: Abort cancellable work during teardown and handle abort separately from real failures.

function loadUser(userId) { const controller = new AbortController(); const promise = fetch(`/api/users/${userId}`, { signal: controller.signal }) .then(response => { if (!response.ok) { throw new Error(`Failed to load user: ${response.status}`); } return response.json(); }) .catch(error => { if (error instanceof DOMException && error.name === 'AbortError') { return undefined; } throw error; }); return { promise, cancel: () => controller.abort(), }; } 
const request = loadUser(userId); void request.promise.then(user => { if (user) profileStore.setUser(user); }); // When the screen unmounts or userId changes: request.cancel(); 

For non-cancellable operations, use a generation token so obsolete results are ignored. This protects correctness, but the underlying operation can still retain resources until it finishes.

Back to Top

5. Framework Lifecycle Patterns

5.1 React

Every effect that acquires a resource should release it.

useEffect(() => { const controller = new AbortController(); const socket = new WebSocket(url); const intervalId = window.setInterval(refreshPresence, 10_000Source:https://dev.to/zeeshanali0704/frontend-memory-leaks-detection-diagnosis-and-prevention-2j8m

Related posts