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
- What a Memory Leak Is
- How Browser Memory Works
- Memory Leak vs High Memory Usage
- Common Frontend Leak Sources
- Framework Lifecycle Patterns
- A Repeatable Detection Workflow
- Chrome DevTools Deep Dive
- Automated Memory Regression Testing
- Production Monitoring
- Prevention by Design
- System Design for Long-Running Apps
- End-to-End Incident Case Study
- Debugging Checklist
- 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.
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.
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:
- Repeatable growth after the same lifecycle action
- Heap or resource comparisons that identify what accumulates
- A retaining path or owner keeping objects alive
- Product evidence that those objects are no longer needed
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.
IntersectionObserverResizeObserverMutationObserverPerformanceObserver
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.
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_000);
window.addEventListener('online', refreshPresence, {
signal: controller.signal,
});
return () => {
controller.abort();
socket.close();
window.clearInterval(intervalId);
};
}, [url]);
React Strict Mode intentionally runs an extra setup-cleanup cycle in development. Treat duplicate subscriptions or requests as evidence that cleanup is incomplete, not as a reason to disable Strict Mode.
Request ownership should also be explicit when dependencies change:
useEffect(() => {
const controller = new AbortController();
async function loadReport() {
try {
const response = await fetch(`/api/reports/${reportId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Failed to load report: ${response.status}`);
}
setReport(await response.json());
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
reportError(error);
}
}
void loadReport();
return () => controller.abort();
}, [reportId]);
Cancellation stops obsolete work when possible. For an operation that cannot be cancelled, use a generation token or stale-result guard so an old response cannot update the current screen. Ignoring a stale result protects correctness; cancellation additionally releases resources and avoids wasted work.
Common React leak risks:
- Effects without cleanup
- Effects that rerun because dependencies are unstable
- Query caches with unlimited retention
- Portals or imperative widgets not destroyed on unmount
- External stores that never unsubscribe
5.2 Vue
import { onMounted, onUnmounted } from 'vue';
let observer: ResizeObserver;
onMounted(() => {
observer = new ResizeObserver(updateLayout);
observer.observe(container.value);
});
onUnmounted(() => {
observer.disconnect();
});
5.3 Angular and RxJS
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
const destroyRef = inject(DestroyRef);
updates$
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(updateView);
Prefer lifecycle-aware subscription operators over manually maintained subscription arrays.
5.4 Framework-independent ownership
The same rule applies everywhere:
setup/acquire cleanup/release
---------------------------------------------------------------
addEventListener removeEventListener / abort
setInterval clearInterval
requestAnimationFrame cancelAnimationFrame
subscribe unsubscribe
observe disconnect
new WebSocket close
new Worker terminate
createObjectURL revokeObjectURL
open stream/reader cancel/releaseLock
6. A Repeatable Detection Workflow
Do not start by staring at a large heap snapshot. First create a controlled reproduction.
Diagnostic decision tree
Does the post-GC JavaScript heap grow after a repeated flow?
|
+-- Yes -> Compare heap snapshots
| |
| +-- Detached nodes grow -> inspect DOM retainers/listeners
| +-- One constructor grows -> inspect retaining paths
| +-- Arrays/Maps grow -> inspect cache and store ownership
|
+-- No -> Does total tab memory still grow?
|
+-- Images/media-heavy -> inspect decoded media and buffers
+-- Canvas/WebGL -> inspect GPU resources and explicit disposal
+-- Workers grow -> profile and terminate worker lifecycles
Do you know exactly when allocations occur?
|
+-- Yes -> Allocation instrumentation on timeline
+-- No -> Performance Monitor first, then narrow the flow
Step 1: Define a cycle
Choose an action that should return the app to the same state:
- Open the dashboard.
- Open and close a detail panel.
- Navigate away.
- Return to the starting screen.
Step 2: Warm up
Run the cycle once or twice so lazy modules, caches, fonts, and one-time framework initialization do not pollute the measurement.
Step 3: Record a baseline
Track:
- JavaScript heap size
- DOM node count
- Listener/subscription count where available
- Active timers, workers, sockets, and observers
- Application cache entry counts
Step 4: Repeat the cycle
Run it 10–20 times with the same data and timing.
Step 5: Force or wait for GC
In a controlled debugging session, use the DevTools garbage-collection button. In automation, use a browser launched with explicit GC support when available.
Step 6: Compare the post-GC baseline
Healthy applications may keep a small amount of warmed cache. A leak shows consistent growth proportional to cycle count.
Step 7: Find the retaining path
Once a leaking constructor or detached node is identified, inspect the shortest retaining path to the GC root. Fix the owner keeping the object alive rather than nulling arbitrary variables.
7. Chrome DevTools Deep Dive
7.1 Performance Monitor
Open DevTools Command Menu and choose Show Performance monitor.
Watch:
- JS heap size
- DOM nodes
- Event listeners
- CPU usage
- Documents and frames
This is the fastest way to confirm a trend, but it does not identify the retaining reference.
7.2 Heap snapshots
- Open Memory.
- Select Heap snapshot.
- Take a baseline snapshot.
- Repeat the suspected action.
- Force GC.
- Take a second snapshot.
- Choose Comparison.
- Sort by positive delta or retained size.
Useful filters:
-
Detachedfor detached DOM trees - A known component or model constructor
-
(closure)for retained lexical scopes - Large
Array,Map, orSetinstances
The Retainers panel answers the most important question: why is this object still reachable?
7.3 Allocation instrumentation on timeline
Use this when you know when the leak occurs but not what is allocated.
- Start recording.
- Perform the action.
- Stop recording.
- Focus on allocations that survive GC.
- Inspect their stack traces and retainers.
7.4 Allocation sampling
Sampling has lower overhead and is useful for broad allocation hotspots. It is less precise than instrumentation but better for longer sessions.
7.5 Chrome Task Manager
Open Chrome Task Manager with Shift+Esc to compare the tab's total memory footprint with JavaScript heap measurements. Total memory also includes DOM, images, GPU resources, media buffers, and browser internals.
7.6 Programmatic measurement
performance.measureUserAgentSpecificMemory() can provide a more complete measurement where supported, but it is experimental and requires cross-origin isolation.
async function readMemory() {
if (!('measureUserAgentSpecificMemory' in performance)) {
return null;
}
const result = await performance.measureUserAgentSpecificMemory();
return result.bytes;
}
Do not make production correctness depend on this API.
8. Automated Memory Regression Testing
Memory tests are noisy, so test repeated growth rather than one absolute reading.
Use layered tests because each layer proves something different:
| Test layer | What it verifies | What it does not prove |
|---|---|---|
| Unit | Cleanup callbacks run; counters and subscriptions return to baseline | Browser objects are collectible |
| Browser integration | Repeated navigation and teardown leave the expected UI/resource state | Absence of hidden retained objects |
| Memory regression | Post-GC trends, detached nodes, or retained constructors remain within budget | The root cause without snapshot analysis |
No single heap-size threshold proves correctness. Treat a budget failure as a regression signal that triggers comparison and retaining-path analysis.
Browser test strategy
launch browser
-> warm up application
-> collect baseline
-> run user flow N times
-> request GC in test environment
-> collect final measurement
-> fail if retained growth exceeds budget
Example Playwright-style flow:
test('detail panel does not leak across repeated use', async ({ page }) => {
await page.goto('/dashboard');
for (let index = 0; index < 20; index += 1) {
await page.getByRole('button', { name: 'Open details' }).click();
await page.getByRole('button', { name: 'Close details' }).click();
}
const remainingDialogs = await page.locator('[role="dialog"]').count();
expect(remainingDialogs).toBe(0);
});
DOM-count assertions do not prove the absence of leaks, but they provide a stable first guard. A dedicated Chromium test environment can add heap snapshots or Chrome DevTools Protocol metrics.
Good regression signals
- Post-GC heap growth per cycle
- Detached-node count
- DOM node count after returning to baseline
- Event-bus subscriber count
- Active socket/worker count
- Cache entries and estimated bytes
Avoid flaky tests
- Warm up before measuring.
- Use the same data on every cycle.
- Disable unrelated polling and analytics.
- Compare trends across several runs.
- Keep generous budgets for engine noise.
- Run deep memory tests on scheduled CI if they are too expensive for every pull request.
9. Production Monitoring
Production browsers do not expose the same deterministic controls as DevTools. Use indirect signals and feature-level telemetry.
Useful telemetry
| Signal | What it can reveal |
|---|---|
| Session duration vs responsiveness | Long-session degradation |
| Route transitions vs heap where supported | Screen-specific growth |
| DOM node count sampled periodically | Detached or duplicated UI |
| Cache entries and estimated bytes | Unbounded application caches |
| Active sockets, workers, and subscriptions | Missing teardown |
| Long tasks and INP over session age | GC pressure and degraded interaction |
| Tab reloads or crash reports | Severe memory pressure |
Sample sparingly
Memory and DOM measurements can be expensive. Sample a small percentage of sessions, avoid collecting user content, and aggregate values into buckets.
Correlate with lifecycle events
Include dimensions such as:
- Route or feature
- App version
- Session age
- Device-memory class
- Number of repeated feature cycles
This makes it possible to identify patterns such as “heap grows after every workspace switch on version 4.2.”
10. Prevention by Design
10.1 Make resource ownership explicit
Every long-lived resource should have one owner responsible for cleanup.
interface Disposable {
dispose(): void;
}
Composite ownership helps complex screens:
class Disposables {
#cleanups = new Set<() => void>();
#disposed = false;
add(cleanup: () => void): () => void {
if (this.#disposed) {
cleanup();
return () => {};
}
let active = true;
const run = () => {
if (!active) return;
active = false;
this.#cleanups.delete(run);
cleanup();
};
this.#cleanups.add(run);
return run;
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
const cleanups = [...this.#cleanups].reverse();
this.#cleanups.clear();
const errors: unknown[] = [];
for (const cleanup of cleanups) {
try {
cleanup();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, 'One or more cleanup operations failed');
}
}
}
This disposer is idempotent, releases resources in reverse acquisition order, executes late registrations immediately, and continues cleanup after an individual failure. Callers must report or handle the aggregate error according to the application's error policy.
10.2 Prefer bounded data structures
Set limits for:
- API response caches
- Infinite-scroll pages
- Chat messages retained in memory
- Analytics queues
- Deduplication sets
- Undo/redo history
- Image and media buffers
10.3 Use weak references only for the right problem
WeakMap and WeakSet are useful when metadata should not keep object keys alive.
const metadataByElement = new WeakMap();
metadataByElement.set(element, { measuredAt: Date.now() });
They are not general-purpose caches:
- Entries are not enumerable.
- Collection timing is nondeterministic.
- They do not replace explicit cleanup for listeners, timers, or resources.
Avoid relying on FinalizationRegistry for correctness. Finalizers may run late or not before process termination.
10.4 Make cleanup testable
Expose diagnostics in development:
const diagnostics = {
activeSubscriptions: 0,
activeSockets: 0,
cacheEntries: 0,
};
Tests can assert that counts return to baseline after a feature is destroyed.
10.5 Review lifecycle in code review
When code acquires a resource, reviewers should look for the corresponding release in the same change.
11. System Design for Long-Running Apps
Memory management becomes an architectural concern for dashboards, editors, communication tools, trading interfaces, and kiosk applications that remain open for hours or days.
Bound the in-memory working set
Server history
|
v
Paginated query cache --evict--> bounded recent pages
|
v
Normalized entity cache --TTL/LRU--> active entities
|
v
Virtualized viewport --> only visible DOM nodes
Separate durable state from view state
- Durable data belongs in server storage or IndexedDB.
- Query caches need stale and garbage-collection policies.
- View state should disappear when its owning route or component disappears.
- Large binary data should be streamed or persisted instead of retained indefinitely.
Design reset boundaries
Some applications benefit from explicit reset points:
- Clear workspace-scoped stores when switching workspaces.
- Dispose editor models when tabs close.
- Drop old chat pages outside the navigation window.
- Recreate third-party widgets that do not expose reliable cleanup.
Establish budgets
Illustrative budgets for a long-running SPA:
| Metric | Example target |
|---|---|
| Post-GC heap growth after 20 repeated flows | Less than 5 MB |
| Detached DOM nodes after route exit | 0 |
| Event-bus subscriptions after route exit | Return to baseline |
| In-memory chat messages | Last 500 per active channel |
| Undo history | Last 100 operations or 20 MB |
Budgets must reflect product behavior and target devices. The important property is that growth is intentional and bounded.
12. End-to-End Incident Case Study
Symptom
Users report that an enterprise reporting dashboard becomes slow after switching workspaces for 30–60 minutes. Production telemetry shows worsening INP as session age and workspace-switch count increase.
Reproduction
The team creates one controlled cycle:
- Open workspace A.
- Open and close the report drawer.
- Switch to workspace B.
- Return to workspace A.
After warm-up, the flow is repeated 20 times. The post-GC JavaScript heap rises by roughly 3 MB per cycle, and detached DOM nodes increase.
Isolation
Heap snapshot comparison shows:
- 20 retained
ReportDrawerModelinstances - A detached drawer subtree for each cycle
- One global
keydownlistener per old drawer
The shortest retaining path is:
window
-> EventListener("keydown")
-> onShortcut closure
-> ReportDrawerModel
-> report rows
-> detached drawer DOM
Root cause
The drawer registers a global keyboard shortcut with an inline closure. Closing the drawer removes the DOM but never removes the listener. The closure retains the model, report data, and detached subtree.
Fix
function mountReportDrawer(model: ReportDrawerModel) {
const controller = new AbortController();
window.addEventListener(
'keydown',
event => {
if (event.key === 'Escape') model.close();
},
{ signal: controller.signal }
);
return () => controller.abort();
}
The workspace owner stores and invokes the returned cleanup before replacing its drawer model.
Regression coverage
- Unit test: disposing the drawer returns the shortcut-listener counter to baseline.
- Browser integration test: opening and closing the drawer 20 times leaves no dialogs or drawer models registered.
- Scheduled memory test: the repeated workspace flow stays within an illustrative post-GC growth budget and produces zero detached drawer roots.
Rollout verification
The fix is released behind a feature flag. The team compares:
- Heap trend by app version
- INP by session age
- Detached-node diagnostics from sampled internal sessions
- Active drawer/listener counters
After rollout, memory returns near the warmed baseline and long-session INP no longer degrades with workspace switches.
Principal-level lesson
The durable fix is not merely removeEventListener. The team establishes an ownership contract for global resources, adds lifecycle counters, creates a repeated-flow regression test, and verifies the outcome in production.
13. Debugging Checklist
Reproduction
- [ ] Can the issue be reproduced with one repeated user flow?
- [ ] Was the application warmed up before measuring?
- [ ] Does the post-GC baseline rise after each cycle?
Retention
- [ ] Are detached DOM nodes increasing?
- [ ] Do heap comparisons show a growing constructor count?
- [ ] What is the shortest retaining path to a GC root?
- [ ] Is a closure retaining more state than necessary?
Lifecycle resources
- [ ] Are global event listeners removed?
- [ ] Are timers and animation frames cancelled?
- [ ] Are observers disconnected?
- [ ] Are event-bus and store subscriptions removed?
- [ ] Are requests aborted when no longer useful?
- [ ] Are WebSockets, channels, workers, and streams closed?
- [ ] Are Blob URLs revoked and media tracks stopped?
Data structures
- [ ] Are caches, queues, maps, sets, and histories bounded?
- [ ] Do route- or workspace-scoped stores reset?
- [ ] Are virtualized items and old pages evicted?
Verification
- [ ] Does the same repeated flow return close to baseline after the fix?
- [ ] Is there a regression test or diagnostic counter?
- [ ] Are production metrics available for long sessions?
Incident response
- [ ] Is there a short-term mitigation for affected users?
- [ ] Is the leaking feature isolated behind a flag or rollback?
- [ ] Is the retaining owner documented in the root-cause analysis?
- [ ] Does regression coverage reproduce the original lifecycle?
- [ ] Are rollout metrics segmented by release version and session age?
14. Interview Questions and Answers
What is a frontend memory leak?
A frontend memory leak is unintended retention: the UI no longer needs an object, but a reachable reference from a GC root prevents collection. Common roots include global listeners, active timers, subscriptions, sockets, observers, and module-level collections.
How do you prove an application leaks?
Define a repeatable flow that returns to the same state, warm up the app, take a baseline, repeat the flow, force or wait for GC, and compare the new baseline. Consistent post-GC growth is stronger evidence than one high memory reading.
What is a detached DOM node?
It is a node removed from the live document but still referenced by JavaScript. The reference can keep the entire detached subtree and associated closures alive.
Why can a small callback retain a large amount of memory?
Its closure may reference a component, store, or large response object. The callback's shallow size is small, but its retained size includes everything that would become collectible if the callback were released.
Does every unresolved promise cause a memory leak?
No. A pending promise retains its callbacks until it settles, which is often temporary. It becomes a leak risk when operations never settle, are created repeatedly, or are retained by another long-lived structure.
When should you use WeakMap?
Use it for metadata keyed by objects when the metadata must not keep the keys alive. Do not use it as a replacement for resource cleanup or as an observable cache.
How would you design a leak-resistant event bus?
Return an unsubscribe function, support AbortSignal, remove empty topic sets, optionally expose subscriber counts in development, and test that counts return to baseline after component teardown.
How do you prevent memory leaks in long-running SPAs?
Make ownership explicit, pair every acquisition with cleanup, bound all caches and histories, virtualize large views, reset scoped state, monitor long-session behavior, and automate repeated-flow regression tests.
A dashboard heap grows after every workspace switch. How do you isolate the cause?
Warm up the app, define a repeatable workspace-switch cycle, compare post-GC heap and DOM counts, then compare snapshots by constructor delta. Follow the shortest retaining paths for workspace-scoped models and detached nodes. Also inspect global listeners, subscriptions, query caches, and workers owned by the previous workspace.
How do you distinguish a leak from a legitimate cache?
A legitimate cache has an owner, purpose, maximum size or byte budget, eviction and invalidation rules, and observable metrics. Its growth should plateau. A leak retains data without a continuing product need or grows beyond its declared policy.
How would you enforce lifecycle correctness across a large codebase?
Provide standard disposable abstractions, lifecycle-aware framework hooks, bounded cache utilities, and diagnostics counters. Add linting or review rules for resource acquisition, test teardown behavior, run repeated-flow browser tests, and monitor long-session production signals.
Why might total memory rise while the JavaScript heap remains stable?
Decoded images, canvas or WebGL resources, GPU textures, media buffers, DOM/layout structures, network buffers, and worker processes can grow outside the measured JavaScript heap. Use Chrome Task Manager or browser tracing to investigate the relevant category.
How do you detect memory regressions in CI without making tests flaky?
Warm up first, use deterministic data, disable unrelated background activity, repeat the same flow, compare trends rather than a single sample, and use generous evidence-based budgets. Keep stable lifecycle assertions in pull-request tests and run expensive heap analysis in a controlled scheduled job.
Summary
Memory leaks are ownership failures. The garbage collector can reclaim only objects that are unreachable; it cannot infer that a listener, cache entry, socket, or detached node is no longer useful.
The reliable approach is:
- Reproduce a flow that returns to baseline.
- Confirm repeated post-GC growth.
- Identify the retained object.
- Follow its retaining path to the owner.
- Fix lifecycle cleanup or bound the data structure.
- Add a regression test and production signal.
- Verify the rollout across release version, session age, and affected flows.
Top comments (0)