Framework-Specific Memory Optimization

Component trees, reactivity systems, and dependency-injection containers exist to hide bookkeeping from you — and the reference bookkeeping they hide is exactly the bookkeeping that determines whether an object gets collected. This area is written for frontend and full-stack engineers who can already read a heap snapshot but keep finding that React, Vue, and Angular applications grow their heap in ways the framework documentation never quite explains. Every framework leak in this guide resolves to one of two V8-level facts you already know: a detached DOM node that some closure still points at, or a closure that captured more scope than its lifetime warranted. The framework is only ever the thing holding the reference. We cover the three dominant leak shapes — React component and lifecycle leaks, Vue reactivity retention, and Angular RxJS subscription leaks — and tie each back to the collector behaviour documented in the JavaScript memory fundamentals area. For the server side of the same abstractions — SSR hydration and per-request retention — see the Node.js server-side memory area.

Architecture Overview

The three major frameworks reach the same runtime through different abstractions, but the abstraction is always a long-lived data structure that owns references to your component state. In React, that structure is the fiber tree: a persistent linked list of fiber nodes, each holding a component’s hooks, props, and a pointer to its rendered DOM. In Vue 3, it is the reactivity graph: every ref and reactive object maintains a set of subscriber effects, and every effect holds a back-reference to the reactive sources it read. In Angular, it is the injector hierarchy plus the Zone.js patch layer: services live for the lifetime of their injector, and Zone wraps every async callback so change detection can run afterwards.

Each abstraction has a legitimate reason to hold your objects, and each has a corresponding failure mode where the hold outlives the component. The diagram below maps the three abstractions onto the shared V8 retention that actually keeps memory alive after a component should have been collected.

How Framework Abstractions Retain V8 Memory Three columns — React fiber tree, Vue reactivity graph, and Angular injector with Zone.js — each hold a hidden reference that, when cleanup is missed, anchors a detached DOM subtree or a captured closure scope in the V8 heap so the garbage collector cannot reclaim it. Framework Abstraction → V8 Retention React Fiber Tree Persistent fiber nodes hold hooks, props, effect closures Missed useEffect cleanup keeps the subscription callback alive after unmount Vue Reactivity Graph Each ref keeps a set of subscriber effects (watchers) A watcher outside an effect scope survives component teardown Angular Injector + Zone Services live for injector life; Zone wraps every callback An open RxJS subscription holds the component via its observer closure Shared V8 Heap Retention (reachable from a GC root) Detached DOM subtree element removed from document but still referenced by a live listener Captured closure scope effect/observer holds props, state, and the whole component instance The garbage collector cannot reclaim any object on a path back to a root — cleanup exists solely to break that path at unmount. Fix = sever the reference at teardown, not tune the collector.

The single unifying rule: mark-and-sweep collection reclaims an object only when no path of strong references leads back to a GC root. A framework component becomes garbage the moment its abstraction — fiber, effect, or injector — stops pointing at it. Every technique in this area is a way to make that stop happen on time.

Core Mechanics 1 — React Reconciliation and Effect Cleanup

React’s reconciler keeps two fiber trees in memory: the current tree that matches the screen and a work-in-progress tree it builds during a render. When a component unmounts, its fiber is detached from the tree and becomes eligible for collection — but only if nothing else holds it. The hook that most often keeps it alive is useEffect, because an effect can register a subscription, timer, or listener whose callback closes over the component’s props and state. The cleanup function returned from the effect is the sole mechanism React gives you to sever that reference before the fiber is dropped.

The measured impact is stark. A dashboard that mounts a component subscribing to a WebSocket on every route entry, without returning a cleanup, retained roughly 2–4 MB per visit in our test harness — one detached component subtree plus its buffered messages. After ten navigations the tab held 20–40 MB of pure garbage that no GC cycle could touch, because each stale effect callback kept its fiber reachable. Returning the cleanup dropped the post-GC delta to under 0.2 MB per cycle.

This code shows a subscription effect that leaks versus one that cleans up on unmount, the single most common React memory bug.

// LEAKS: no cleanup — the socket callback closes over
// setState and keeps the unmounted fiber reachable.
useEffect(() => {
  const socket = new WebSocket(url);      // opens on mount
  socket.onmessage = (e) => {             // closure over setState
    setMessages((prev) => [...prev, e.data]);
  };
  // no return — React has nothing to call on unmount
}, [url]);

// SAFE: the returned function runs before unmount and
// on every re-run, breaking the reference chain.
useEffect(() => {
  const socket = new WebSocket(url);      // opens on mount
  socket.onmessage = (e) => {
    setMessages((prev) => [...prev, e.data]);
  };
  return () => socket.close();            // severs the closure
}, [url]);
// Verify in DevTools → Memory → Heap Snapshot →
// Comparison view: Detached nodes delta should be 0.

The deeper walkthrough — including why StrictMode double-invokes effects to surface exactly this class of bug, and how a stale setInterval retains an entire component — lives in the React component memory leaks and lifecycle cleanup guide.

Core Mechanics 2 — Vue Reactivity and Effect Scopes

Vue 3’s reactivity is a bidirectional graph. When an effect (a watch, watchEffect, computed, or a component’s render function) reads a ref or reactive property, Vue records a dependency in both directions: the ref remembers the effect, and the effect remembers the ref. This is what lets a change to state re-run exactly the effects that depend on it. The retention consequence is that a ref stays alive as long as any effect subscribed to it stays alive — and an effect stays alive until its owning scope is stopped.

Inside a component, Vue automatically ties every effect to the component’s effect scope and stops them on unmount, so the common case is leak-free. The leak appears when you create a watcher or computed outside a component scope — in a shared composable, a module-level singleton, or a manually created scope you forget to stop. That effect never gets stopped, so it holds its reactive dependencies, which in turn may hold DOM refs or large arrays. The full treatment of scope ownership and the effectScope API is in the Vue reactivity retention guide, which also covers store retention in Pinia and Vuex.

The measured signature: a composable that starts a watch on a global store without stopping it retained the watching component after unmount. In a list view mounting 50 such rows per page, ten page cycles retained roughly 12–18 MB of stale watcher closures and their captured row data, verified as a growing count of ReactiveEffect objects in the snapshot delta.

This example contrasts a leaking module-level watcher with one wrapped in an explicit, stoppable effect scope.

import { ref, watch, effectScope } from 'vue';

// LEAKS: watcher created at module scope never stops;
// it retains `source` and its callback forever.
const source = ref(0);
watch(source, (v) => updateAnalytics(v));  // no owner

// SAFE: bind the watcher to a scope you can stop
// when the consuming feature tears down.
const scope = effectScope();               // owns effects
scope.run(() => {
  watch(source, (v) => updateAnalytics(v));// tied to scope
});
// Later, on teardown:
scope.stop();                              // stops all effects
// Confirm via DevTools → Memory → Heap Snapshot:
// ReactiveEffect count should not grow per cycle.

Core Mechanics 3 — Angular Subscriptions and Zone.js

Angular’s two retention roots are the injector hierarchy and the Zone.js change-detection layer. Services provided at the root injector live for the whole application; that is by design. The leak surface is RxJS: when a component subscribes to an observable — a router event stream, a service Subject, a fromEvent listener — the subscription creates an observer closure that captures the component instance. If the observable outlives the component and you never unsubscribe, that observer keeps the component, its template, and its rendered DOM reachable. Zone.js compounds the visibility problem by patching every async API so change detection can fire afterwards; a patched setInterval or addEventListener callback is another closure that pins the component.

The measured impact: a component subscribing to a long-lived service Subject in ngOnInit without unsubscribing retained roughly 1–3 MB per instance. Across a data grid that recreated 100 cell components on each filter change, five filter operations without teardown held 30–50 MB of detached component views, visible as a climbing Subscriber and Detached HTMLElement count.

You diagnose this the same way regardless of framework. To confirm subscriptions are the retaining edge, take a snapshot before and after a mount/unmount cycle via DevTools → Memory → Heap Snapshot → take snapshot → run cycle 10× → force GC (the trash-can icon) → take second snapshot → Comparison view → sort by Retained Size, then filter for Subscriber. For a Node-driven CI check you can also run the app under node --expose-gc --max-old-space-size=2048 in a headless harness and assert process.memoryUsage().heapUsed stability across cycles.

This snippet shows the takeUntil teardown pattern that unsubscribes every stream when the component is destroyed.

import { Subject, takeUntil } from 'rxjs';

export class GridCell implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();   // teardown signal

  ngOnInit() {
    this.store.rows$
      .pipe(takeUntil(this.destroy$))       // auto-unsub gate
      .subscribe((rows) => this.render(rows));
  }

  ngOnDestroy() {
    this.destroy$.next();                   // completes all
    this.destroy$.complete();               // releases Subject
  }
}
// Every stream piped through takeUntil(this.destroy$)
// unsubscribes together, dropping the observer closure
// and letting the component view be collected.

The full comparison of takeUntil, the async pipe, DestroyRef, and manual Subscription.unsubscribe(), plus Zone.js overhead, is in the Angular RxJS subscription leaks guide.

Observability & Instrumentation

Framework leaks are cyclical: they appear on mount/unmount boundaries, not as a steady drip. That makes them ideal for automated per-cycle assertions. The two instruments below give you a browser-side heap delta per interaction and a Node-side per-cycle guard you can wire into CI.

This browser helper records the heap before and after a repeatable interaction so you can flag any cycle that fails to return to baseline.

// Chromium-only heap delta probe for mount/unmount cycles.
// Wrap the interaction you want to audit in runCycle().
async function auditCycle(runCycle, iterations = 10) {
  const before = performance.memory.usedJSHeapSize; // bytes
  for (let i = 0; i < iterations; i++) {
    await runCycle();          // e.g. open then close a modal
  }
  // Give the collector a chance; requires --expose-gc or
  // DevTools "collect garbage" for a deterministic read.
  if (window.gc) window.gc();
  const after = performance.memory.usedJSHeapSize;
  const perCycleKb =
    (after - before) / iterations / 1024;           // KB/cycle
  if (perCycleKb > 200) {      // 200 KB/cycle = suspect leak
    console.warn(
      `Leak suspected: ${perCycleKb.toFixed(1)} KB/cycle`
    );
  }
}

This Node.js probe asserts heap stability across repeated renders in a headless test, suitable for a CI gate on server-rendered or jsdom-mounted components.

// Run with:
//   node --expose-gc --max-old-space-size=2048 audit.js
// Fails the process if heap grows beyond the threshold.
function assertStableHeap(renderOnce, cycles = 50) {
  global.gc();                         // clean starting point
  const start = process.memoryUsage().heapUsed; // bytes
  for (let i = 0; i < cycles; i++) {
    renderOnce();                      // mount + unmount once
  }
  global.gc();                         // reclaim all garbage
  const end = process.memoryUsage().heapUsed;
  const growthMb = (end - start) / 1048576;       // MB total
  // Allow small jitter; fail on sustained per-cycle growth.
  if (growthMb > 5) {
    throw new Error(
      `Heap grew ${growthMb.toFixed(1)} MB over ${cycles} cycles`
    );
  }
}

To turn a raw number into a named culprit, pair these probes with a snapshot comparison as described in the interpreting heap snapshots guide — the probe tells you a cycle leaks, the snapshot tells you which fiber, effect, or subscriber is the retaining edge. Measurable payoff: teams running a per-cycle heap assertion in CI catch framework cleanup regressions in the pull request that introduces them, cutting mean time to detection from a post-release incident to a failed check in under 15 minutes.

Structured Workflow

Follow these five steps to take a suspected framework leak from symptom to verified fix.

Step 1 — Pin a repeatable mount/unmount cycle

Action: Choose one interaction that mounts and unmounts a component tree identically every time — a route toggle, a modal, or a filter that rebuilds a list.

Command / DevTools Path: No tooling yet; script the interaction with Playwright page.click() sequences or a manual click path you can repeat exactly ten times.

Expected Metric: The interaction returns the UI to a visibly identical idle state each cycle.

Impact: A deterministic cycle is what makes the later heap delta attributable to that interaction and nothing else.

Step 2 — Capture a baseline and delta snapshot

Action: Snapshot the heap, run the cycle ten times, force GC, then snapshot again.

Command / DevTools Path: DevTools → Memory → Heap Snapshot → Take snapshot → run cycle ×10 → click the trash-can (Collect garbage) → Take snapshot → select second snapshot → Comparison view.

Expected Metric: For a clean component, the # Delta for its class is 0 after GC. Any positive delta scaling with your cycle count is the leak.

Impact: The Comparison view surfaces the majority of cyclical framework leaks — detached views, stale effects, orphaned subscribers — before they reach production.

Step 3 — Identify the retained framework object

Action: Sort the delta by Retained Size and filter for the framework’s retention primitives.

Command / DevTools Path: In the Comparison view, filter the Class filter box for Detached, then Fiber (React), ReactiveEffect (Vue), or Subscriber (Angular/RxJS).

Expected Metric: A count that grew by roughly your cycle multiple (10 cycles → +10 instances) confirms the object is created once per cycle and never freed.

Impact: Narrows the search from “the heap grows” to a specific object class in seconds.

Step 4 — Trace the retaining path to a GC root

Action: Select one leaked instance and walk its Retainers upward to the root.

Command / DevTools Path: DevTools → Memory → (select instance) → Retainers panel → expand until the path reaches (GC roots).

Expected Metric: The path passes through a listener, timer, subscription, or store you own. If it terminates at Window or a module singleton, you have found the anchoring root.

Impact: The retaining edge is the exact reference your cleanup must break; see detached DOM nodes and memory retention for reading these paths.

Step 5 — Add cleanup and re-verify to zero

Action: Apply the framework’s teardown — a useEffect return, effectScope().stop(), or takeUntil(destroy$) — then repeat Step 2.

Command / DevTools Path: Re-run DevTools → Memory → Heap Snapshot → Comparison view after the fix; or run the Node assertStableHeap guard in CI.

Expected Metric: The # Delta for the previously leaking class returns to 0; per-cycle KB drops below your threshold.

Impact: A verified zero delta converts an intermittent production incident into a regression the CI gate will catch permanently.

Anti-Patterns & Pitfalls

  • Effect with no cleanup return. Symptom: Detached HTMLElement and component fibers accumulate per route change. Root Cause: a useEffect registered a subscription or timer but returned nothing, so React cannot sever the callback closure at unmount. Fix: return a cleanup function that closes the socket, clears the interval, or aborts the controller. Measurable Impact: drops per-navigation retention from 2–4 MB to under 0.2 MB, confirmed by a zero delta in the Comparison view.

  • Watcher or computed created outside an effect scope. Symptom: ReactiveEffect count climbs each time a composable is used. Root Cause: a watch or computed created at module scope or in an unmanaged scope is never stopped, retaining its reactive sources. Fix: create effects inside the component setup, or wrap them in effectScope() and call scope.stop() on teardown. Measurable Impact: eliminates 12–18 MB of stale watcher closures across a list-view cycle.

  • RxJS subscription without unsubscribe. Symptom: Subscriber objects and detached views grow per component instance. Root Cause: a component subscribed to a long-lived observable and never completed the subscription, so the observer closure pins the component. Fix: pipe every stream through takeUntil(this.destroy$) or use the async pipe so Angular manages the subscription. Measurable Impact: removes 1–3 MB per retained component instance.

  • Storing DOM refs in a module-level Map. Symptom: detached nodes retained by an application Map. Root Cause: a strong Map key holds the node after the framework removed it from the document. Fix: use a WeakMap keyed on the element so the entry disappears when the node is collected, as covered in the closure memory leaks guide. Measurable Impact: node metadata is reclaimed automatically with no manual cleanup code.

  • Global event listeners registered in components. Symptom: window/document shows many retained descendants after unmount. Root Cause: window.addEventListener calls in a component were never removed, and each closure captures the component. Fix: remove listeners in cleanup, or attach with an AbortController signal and abort on teardown. Measurable Impact: prevents linear listener growth; each removed listener frees its captured component subtree.

  • Assuming route unmount frees everything. Symptom: heap climbs across navigations despite components clearly leaving the screen. Root Cause: the router removes the view, but an outstanding subscription, timer, or store watcher keeps the instance reachable, so the detached tree survives. Fix: audit every side effect started on mount for a matching teardown; treat mount and unmount as a balanced pair. Measurable Impact: converts monotonic per-route growth into a flat post-GC baseline.

Frequently Asked Questions

Why does memory grow every time I navigate between routes in a single-page app?

Route changes unmount old component trees, but any subscription, timer, event listener, or store watcher created during mount that was never cleaned up keeps the component instance — and its detached DOM subtree — reachable from a GC root. The reference survives the unmount, so each navigation adds another retained tree and the heap climbs monotonically. Take a heap snapshot, navigate ten times, force GC, and take a second snapshot: a Detached HTMLElement count that grew by ten confirms one leaked tree per navigation, and the Retainers panel will name the surviving side effect.

Does using a framework make memory leaks more or less likely than vanilla JavaScript?

Frameworks reduce accidental global references but introduce their own long-lived retention roots — the fiber tree, the reactivity dependency graph, and the DI injector. These abstractions hide the raw references that keep objects alive, so leaks become harder to see even though the framework handles the common cases automatically. The underlying V8 retention rules are identical to vanilla JavaScript: an object survives while a path of strong references reaches it from a root. The framework just moves where that path runs, which is why the fix is always to break the reference at the framework’s teardown hook rather than to tune the collector.

How do I tell whether a leak is in my code or the framework itself?

Trace the retaining path in a heap snapshot. If the shortest path from a detached node to a GC root passes through your own closure, subscription, or store, the leak is in application code. Framework internals such as React’s fiber pool or Vue’s global reactive effect are almost always retained by your components, not the reverse — genuine framework leaks are rare and usually already have public issue trackers. The Retainers panel is the arbiter: follow it to the root and read whose reference is doing the holding.

Which framework leaks the most memory: React, Vue, or Angular?

None leaks inherently more; each has a dominant leak shape. React leaks come from missing useEffect cleanup returns. Vue leaks come from watchers and computed refs created outside an effect scope. Angular leaks come from RxJS subscriptions that are never unsubscribed and from Zone.js retaining callbacks. The volume you actually experience depends on how disciplined your cleanup patterns are, not on the framework — a team using takeUntil uniformly in Angular will leak less than a team omitting useEffect returns in React.

Do React function components with hooks leak more than class components?

No — the leak surface is equivalent, but hooks move the cleanup contract into the return value of useEffect, which is easy to omit. A class component’s componentWillUnmount is a single explicit method, whereas each effect owns its own teardown, so a component with three effects has three separate cleanup obligations. The failure mode shifts from forgetting one method to forgetting one of several cleanup returns, which is why StrictMode’s deliberate double-invocation of effects in development is so valuable for surfacing the gap early.