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.
Step-by-Step Fix
- 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. - 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
Related
- React Component Memory Leaks and Lifecycle Cleanup — the parent topic
- Fixing useEffect Cleanup Memory Leaks — cleanup patterns for each resource type
- Unsettled Promises That Leak Their Closures — why aborting async work matters for memory
- Framework-Specific Memory Optimization — the section overview