Bug Fixes & Debugging Deep Dives

Debugging Memory Leaks in React 19

Trace retained objects, clean up effects, and distinguish real leaks from normal allocation, stale requests, and compiler memoization in React 19.

Debugging Memory Leaks in React 19

Prove retention before blaming React

Imagine a dashboard that grows slower after repeated navigation. A rising memory graph is a useful symptom, but it does not identify the cause. The application may be retaining event handlers, storing an unbounded history, loading a legitimate cache, or repeatedly allocating objects that are later collected.

The useful question is: after a feature closes and cleanup runs, what still retains its resources? This article uses illustrative examples. It does not assert a private client incident or a measured reduction in memory use.

React 19 and React Compiler are separate decisions

Installing React 19 does not automatically enable React Compiler. Compiler optimization requires an appropriate build integration and configuration. Check that configuration before discussing memoization as a cause of retention. Memoized values are not inherently leaks; a leak requires an unwanted reachable reference or a resource that outlives its intended owner. React Compiler installation.

Do not infer that an abandoned transition or optimistic update leaks memory simply because it was interrupted. Find the retaining path. A claim about framework internals needs a minimal reproduction and evidence from the affected version.

React runs an effect's cleanup before replacing it after changed dependencies, and when the component unmounts. Development Strict Mode also exercises an extra setup/cleanup cycle. A correct effect should tolerate that cycle. React effect lifecycle.

Give each resource an owner

An effect that creates a subscription, timer, observer, or library instance should release what it owns. This does not mean every effect needs a cleanup function: a one-off synchronization can have nothing to release. Likewise, removing a component's listener from a shared connection is different from closing a connection owned by the whole application.

ResourceOwner's cleanupCommon mistake
Window listenerRemove the same handler with the matching capture optionCreating a different function during removal
IntervalClear the returned interval IDStarting another interval after each render
ObserverDisconnect the instance, or unobserve owned targetsLeaving observations active after the feature closes
Chart or mapUse that library version's disposal methodAssuming removal of its DOM container releases everything
Shared socketUnsubscribe this consumerClosing the connection for unrelated consumers

Listener identity and capture matching are part of the browser API contract. MDN event listener removal.

A minimal React example

This component only subscribes while mounted. The event handler is created once per effect setup and the cleanup removes that exact function. Its initial value is also safe during server rendering.

tsx
import { useEffect, useState } from "react";

export function ViewportWidth() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener("resize", update);
    return () => window.removeEventListener("resize", update);
  }, []);

  return <output>{width === null ? "Measuring viewport" : width + "px"}</output>;
}

export default function ResizeDemo() {
  const [visible, setVisible] = useState(true);
  return (
    <section>
      <button type="button" onClick={() => setVisible(value => !value)}>
        {visible ? "Hide measurement" : "Show measurement"}
      </button>
      {visible && <ViewportWidth />}
    </section>
  );
}

Use the toggle to reproduce mount/unmount cycles in a React 19 application. The example uses ordinary effect cleanup and does not depend on React Compiler. Type checking can establish the API shape; a browser session is still needed to inspect actual retained objects.

Observers and sockets need precise cleanup

For an IntersectionObserver, capture the node when starting the observation and disconnect the observer during cleanup. Calling observer.disconnect() does not require reading ref.current at all. The earlier explanation that a cleared ref prevents this call was incorrect. A captured node is useful when calling unobserve(node) or removing a listener from that specific node. IntersectionObserver disconnect.

Native WebSocket uses addEventListener and removeEventListener. Methods such as on and off belong to other event libraries and should not be presented as the native interface. If the component creates and exclusively owns a socket, it should remove its handlers and close it when finished. If a service owns the socket, the component should unsubscribe through that service instead. WebSocket close.

Even perfectly cleaned-up listeners will not fix an intentionally retained array that grows forever. A live message view needs a retention policy: keep a bounded window, page older items from storage, or let the user explicitly request history. Choose that bound from product needs, not an arbitrary memory claim.

Measure the same lifecycle before and after

Chrome DevTools provides heap snapshots and allocation tools for investigating retained objects. Compare snapshots after repeating the same action and inspect the references that keep suspicious objects reachable. Detached DOM nodes deserve investigation, but the label alone is not a diagnosis. Chrome's memory investigation guide.

Use a repeatable sequence:

  1. Open a clean session, let initial loading settle, and capture a baseline.
  2. Open and close the suspected feature several times using the same actions.
  3. Capture another snapshot under comparable collection conditions.
  4. Follow retaining paths for unexpected growth; identify the owning subscription, cache, or object.
  5. Fix that owner and repeat the same sequence.

Distinguish temporary allocation from sustained retention. Also distinguish a stale network response updating the wrong screen from a memory leak: both are defects, but they need different evidence. Record browser version, build mode, actions, and snapshots with the investigation so another developer can reproduce it.

Sardar Mukhshaf

Sardar Mukhshaf

Full-Stack & DevOps Engineer

I build scalable web applications, secure backend systems, and production-ready cloud infrastructure. My engineering focus spans interface design, API architecture, deployment pipelines, and observability. Follow for more deep dives into system architecture, performance engineering, and emerging technologies.

More from the practice

Keep the useful ideas close.

Follow new articles with your preferred RSS reader.

Open the RSS feed ↗