Short answer
No—a failure under StrictMode's double-invocation does not automatically mean a production bug today, but it almost always means the effect is fragile. And the decision between refactoring for idempotency versus scoping <StrictMode> comes down to one question: does the effect's resource have a correct cleanup path, or is the cleanup missing entirely?
What is actually happening
In React 18 and later, development builds with <StrictMode> mount each component, then immediately unmount and remount it. Every effect runs setup → cleanup → setup. Production builds run effects once per real mount. So a counter that increments twice in dev increments once in production—today.
The catch: StrictMode is simulating a remount. Any real remount in production—navigating away and back, a Suspense boundary re-revealing content, a key change, or future React features that preserve state across unmounts—exercises the same path. If your effect leaks on the simulated remount, it leaks on the real one.
Separating signal from noise
A dev-only double-invocation failure indicates a genuine bug when the effect acquires something without releasing it: a WebSocket opened but never closed, an interval never cleared, a global third-party object mutated without a teardown. These leak on any real remount, not just StrictMode's.
It is usually not a production bug when the effect is idempotent by nature (re-registering the same listener with the same arguments) or when the duplicated action is dev-only noise, like a logging call. The test is simple: unmount and remount the component in a production build and check whether the external resource is correctly released and reacquired.
The fix pattern
Return a cleanup that fully reverses the setup. For async work, guard against the discarded first run:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(() => {}); // ignore abort from the discarded run
return () => controller.abort();
}, [url]);
For resources that must survive remounts (a shared socket), move ownership out of the component: a module-level manager with reference counting, so the first cleanup decrements but does not destroy what the second setup still needs. Avoid the didMount ref flag—it suppresses the symptom while leaving the effect broken for real remounts.
Refactor vs. scope StrictMode: decision criteria
- Refactor for idempotency when the component is actively developed, the resource has a clean teardown API, or the subtree will ever sit under Suspense, routing, or conditional mounting. This is the default.
- Scope StrictMode around the legacy subtree (wrap healthy branches, leave the legacy branch outside) only when the third-party library holds global state with no teardown, the subtree is frozen, and you have verified via a production mount/unmount test that no real leak occurs. Document why the boundary exists.
- Never remove StrictMode at the root to silence one noisy effect—you lose the check for every other component.
Verification
Add a console.log in setup and cleanup; in dev you should see exactly one setup → cleanup → setup per mount. Then build for production (npm run build + serve) and confirm one setup per mount. Finally, write a test that mounts, unmounts, and asserts the mock socket/timer is fully released—this is the check that catches real production leaks regardless of StrictMode.
Note: this mount-unmount-remount cycle is specific to React 18+; earlier versions only double-invoked render and certain lifecycles, so verify against your installed React version.