React Strict Mode Double Effects and Leak Detection

In development, your subscription fires twice, two WebSocket connections open, and a listener is registered two times — and someone proposes removing <StrictMode> to “fix” it. This guide from React Component Memory Leaks and Lifecycle Cleanup, part of Framework-Specific Memory Optimization, explains why React deliberately runs effects twice in development, why the duplicates it reveals are the same bugs that leak memory in production, and how to use Strict Mode as a free leak detector.

Symptom Root Cause Immediate Action Measurable Impact
Two subscriptions/connections in development Effect has no cleanup, so setup → cleanup → setup leaves two Add a cleanup that fully undoes the setup One active subscription after mount
Listener fires twice per event in dev Listener added in effect, never removed Remove it in cleanup (AbortController) One handler; no retained closure per remount
Timer runs twice as fast in dev Interval set in effect without clearInterval Clear it in cleanup Correct timing; no stacked timers
Analytics event sent twice in dev Side effect that should run once per user action placed in effect Move to the event handler Correct counts in all modes
Team disables Strict Mode Treating the symptom Keep it; fix cleanups Leaks caught in development

Root Cause: Strict Mode Simulates Unmount and Remount

In development builds, <StrictMode> makes React mount each component, immediately run every effect’s cleanup as if it were unmounting, and then run the setups again — setup → cleanup → setup — once, on initial mount. React does this to check that effects are resilient to being unmounted and remounted, which real applications do constantly: routes change, lists re-key, tabs mount and unmount, and newer features preserve and restore component state. An effect whose cleanup correctly undoes its setup behaves identically after the double invocation: one subscription, one listener, one timer. An effect without a cleanup — or with an incomplete one — leaves two.

Those duplicates are not a Strict Mode quirk to work around; they are a direct preview of production memory leaks. The same missing cleanup that leaves two subscriptions in development leaves one subscription per mount in production, forever: each navigation to the view adds a listener, a socket, an interval or an observer, each holding a closure over the component’s state and props and, through them, often its DOM — the retention chain described in why unmounted React components stay in heap snapshots. Strict Mode compresses “mount it many times” into “mount it once, twice”, so the bug is visible on the first page load instead of after an hour of use.

Strict Mode also double-invokes render functions, state initialisers and reducers in development to surface impure rendering. That can double allocation during development renders, which is why memory measurements should always be made on production builds — but it does not create leaks by itself. The useful signal for memory work is the effect double invocation: if anything accumulates after it, you have found a cleanup bug.

What Strict Mode's double effect reveals Top row, effect with cleanup: setup adds a subscription, cleanup removes it, setup adds it again, ending with one active subscription. Bottom row, effect without cleanup: setup adds a subscription, the empty cleanup does nothing, setup adds a second, ending with two. In production the same missing cleanup leaks one subscription per mount. Effect with cleanup setup: subscribe (1) cleanup: unsubscribe (0) setup: subscribe (1) 1 active ✓ Effect without cleanup setup: subscribe (1) cleanup: nothing (1) setup: subscribe (2) 2 active ✗ production equivalent: +1 leaked subscription (and its closure) on every mount

Step-by-Step Fix

  1. Keep <StrictMode> on in development. Wrap the root in <StrictMode> and run the app locally. Verification: effects log their setup twice on mount in development.
  2. Count active resources. Add development-only counters for subscriptions, sockets, listeners and intervals (see the helper below), or watch Network → WS for duplicate connections. Verification: you can see whether any count is 2 after a single mount.
  3. Pair every setup with a cleanup. For each effect that subscribes, connects, listens, schedules, observes or fetches, return a function that undoes exactly that: unsubscribe, close, remove, clear, disconnect, abort. Verification: counts return to 1 after the double invocation.
  4. Move one-shot actions out of effects. Analytics events and mutations triggered by user actions belong in event handlers; effects are for synchronising with external systems. Verification: those actions fire once in development.
  5. Handle async results after cleanup. For fetches in effects, abort the request in cleanup or ignore results after cleanup, so the first (discarded) run does not write state or keep promises alive. Verification: no “state update on unmounted component”-style double writes; the first request is cancelled.
  6. Confirm with a production memory test. Mount/unmount the view ten times in a production build and check for growth. Verification: detached DOM and subscription counts stay at baseline, matching what Strict Mode now shows.
Production consequence of a missing cleanup In production, a live-price component without cleanup accumulates one active socket subscription per mount, reaching 25 after 25 navigations, each retaining the component's closure. With a cleanup that Strict Mode forced the team to add, active subscriptions stay at 1. 25 0 no cleanup: +1 per mount cleanup added: always 1 navigations to the view (production)

Command and Code Reference

Use case: a development counter that makes duplicates obvious.

// dev-resources.js — import only in development
export const active = new Map();
export function track(name) {
  active.set(name, (active.get(name) || 0) + 1);
  if (active.get(name) > 1) console.warn(`[leak?] ${name} active ×${active.get(name)}`);
  return () => active.set(name, active.get(name) - 1);   // call from cleanup
}

Use case: a socket subscription that survives Strict Mode correctly.

function LivePrice({ symbol }) {
  const [price, setPrice] = useState(null);

  useEffect(() => {
    const untrack = track(`price:${symbol}`);          // dev-only bookkeeping
    const socket = new WebSocket(`wss://prices.example.com/${symbol}`);
    socket.onmessage = (e) => setPrice(JSON.parse(e.data).price);

    return () => {
      socket.onmessage = null;                         // drop the closure over setPrice
      socket.close();                                  // undo the setup completely
      untrack();
    };
  }, [symbol]);

  return <output>{price ?? '…'}</output>;
}

Use case: a fetch in an effect that the first Strict Mode run does not leak.

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/items/${id}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setItem)
    .catch((err) => { if (err.name !== 'AbortError') setError(err); });
  return () => controller.abort();                     // cancels the discarded first run
}, [id]);

Verification and Regression Prevention

With cleanups in place, development shows exactly one of each resource after mount despite the double invocation, and a production mount/unmount test shows no growth. That agreement is the goal: Strict Mode’s result in development predicts production behaviour. If development shows one but production still grows, the leak is in a path Strict Mode does not exercise — a module-level cache, a third-party widget, a subscription created outside effects — and needs the snapshot workflow in fixing useEffect cleanup memory leaks.

Make Strict Mode non-negotiable in the development build, and treat duplicate-resource warnings from the dev tracker as errors in local runs and component tests. Code review should reject effects that set up external resources without returning a cleanup; lint rules and custom hooks (useSubscription, useEventListener, useInterval) that always return cleanup make the correct pattern the easy one.

Comparing development and production With cleanups in place, development under Strict Mode should show exactly one of each resource after mount, and a production mount and unmount test should show no growth. If both agree, the fix holds. If development shows two, a cleanup is missing. If development shows one but production still grows, the leak is on a path Strict Mode does not exercise, such as a module-level cache or third-party widget. Resources after mount / after unmount test Fixed: Strict Mode predicts production dev: 1, prod: flat An effect has no cleanup, or cleanup is incomplete dev: 2 after mount Leak outside effects: module cache, widget, external subscription dev: 1, prod: grows

Edge Cases and Gotchas

Refs used to “run once” hide the bug

Guarding setup with if (ref.current) return; ref.current = true stops the second setup in development, but the resource is still never cleaned up, and the component still leaks in production when it remounts. Write a real cleanup instead.

Singletons that ignore close

Some SDKs return the same connection object on repeated connect() calls and ignore close() while others use it. Reference-count shared connections in your own wrapper so the last user closes them.

Effects in production run once — but components remount

The double invocation is development-only. Production does not run effects twice on mount, but it mounts components many times over a session, which is exactly why the missing cleanup matters.

Strict Mode does not double-invoke event handlers

Handlers run once. If you see a duplicate from an event handler, the cause is elsewhere — often a listener registered twice by an effect without cleanup.

Frequently Asked Questions

Why does React run my effects twice?

In development with <StrictMode>, React runs setup, then cleanup, then setup again on mount to verify that effects can handle unmounting and remounting. It does not happen in production builds.

Should I disable Strict Mode to stop duplicate subscriptions?

No. The duplicates show that an effect lacks a proper cleanup, which in production means a leak on every remount. Add the cleanup and keep Strict Mode as an early warning system.

Does Strict Mode increase memory usage?

In development it double-invokes renders and effects, so allocation is higher and memory measurements are not representative. It does not cause leaks by itself. Measure memory in production builds.

Does Strict Mode catch every memory leak?

No. It only exercises the mount–unmount–mount path of effects on initial render. Leaks caused by module-level caches, subscriptions created outside effects, third-party widgets holding DOM references, or growth that depends on data volume do not show up as duplicates. Use it as a first line of defence and keep production mount/unmount memory tests for the rest.

Can I enable Strict Mode for only part of the app?

Yes. <StrictMode> can wrap any subtree, so a large codebase can adopt it route by route, fixing cleanups as each area is covered. Aim to wrap the whole application eventually, since leaks in unwrapped areas stay invisible in development.

How do I make a fetch in useEffect safe under Strict Mode?

Create an AbortController in the effect, pass its signal to fetch, and call abort() in the cleanup. The first, discarded invocation’s request is cancelled, and its promise does not keep the component’s state setters alive.