DEV Community

ZeeshanAli-0704
ZeeshanAli-0704

Posted on

Frontend Memory Leaks — Detection, Diagnosis and Prevention

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Leaking code:

function mountPanel(panelState) {
  window.addEventListener('resize', () => {
    panelState.recalculate();
  });
}

// Calling mountPanel() for every route visit adds another listener.
// Nothing removes the old callback.
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Leaking code:

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

  requestAnimationFrame(function render() {
    feedStore.renderProgress();
    requestAnimationFrame(render);
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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();
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
module scope
  -> responsesByUrl
  -> every URL ever visited
  -> every response object
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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(),
  };
}
Enter fullscreen mode Exit fullscreen mode
const request = loadUser(userId);
void request.promise.then(user => {
  if (user) profileStore.setUser(user);
});

// When the screen unmounts or userId changes:
request.cancel();
Enter fullscreen mode Exit fullscreen mode

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_000);

  window.addEventListener('online', refreshPresence, {
    signal: controller.signal,
  });

  return () => {
    controller.abort();
    socket.close();
    window.clearInterval(intervalId);
  };
}, [url]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Back to Top


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
Enter fullscreen mode Exit fullscreen mode

Step 1: Define a cycle

Choose an action that should return the app to the same state:

  1. Open the dashboard.
  2. Open and close a detail panel.
  3. Navigate away.
  4. 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.

Back to Top


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

  1. Open Memory.
  2. Select Heap snapshot.
  3. Take a baseline snapshot.
  4. Repeat the suspected action.
  5. Force GC.
  6. Take a second snapshot.
  7. Choose Comparison.
  8. Sort by positive delta or retained size.

Useful filters:

  • Detached for detached DOM trees
  • A known component or model constructor
  • (closure) for retained lexical scopes
  • Large Array, Map, or Set instances

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;
}
Enter fullscreen mode Exit fullscreen mode

Do not make production correctness depend on this API.

Back to Top


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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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.

Back to Top


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.”

Back to Top


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;
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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() });
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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.

Back to Top


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
Enter fullscreen mode Exit fullscreen mode

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.

Back to Top


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:

  1. Open workspace A.
  2. Open and close the report drawer.
  3. Switch to workspace B.
  4. 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 ReportDrawerModel instances
  • A detached drawer subtree for each cycle
  • One global keydown listener per old drawer

The shortest retaining path is:

window
  -> EventListener("keydown")
  -> onShortcut closure
  -> ReportDrawerModel
  -> report rows
  -> detached drawer DOM
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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.

Back to Top


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?

Back to Top


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:

  1. Reproduce a flow that returns to baseline.
  2. Confirm repeated post-GC growth.
  3. Identify the retained object.
  4. Follow its retaining path to the owner.
  5. Fix lifecycle cleanup or bound the data structure.
  6. Add a regression test and production signal.
  7. Verify the rollout across release version, session age, and affected flows.

Back to Top

Top comments (0)