React StrictMode permanently disables your init guard in development
In short
React StrictMode mounts every component, runs cleanup, then remounts. A module-level `if (initialised) return` guard is set on the first mount, survives the cleanup because it lives outside the component, and blocks the second mount from ever re-attaching its listeners. The feature is then dead for the whole dev session while production works fine.

- Affects
- Development only — StrictMode double-mount is not in production
- Trigger
- Init state stored outside the component (module scope, or a ref never reset)
- Symptom
- Listeners attached once, then detached and never re-attached
- Tell
- Works on a production build, dead under `next dev`
My visitor tracking did nothing in development. Not intermittently — never. No errors, no failed requests, no warnings. On a production build it worked perfectly. That gap between the two is the entire fingerprint of this bug, and once you know it you can spot it in about ten seconds.
#The sequence
StrictMode in development deliberately mounts a component, runs its effect cleanup, and mounts it again. The point is to surface effects that are not safe to run twice. It does that job well. It also punishes a very common pattern.
- First mount: the guard is unset, so initialisation runs and listeners are attached.
- The guard is set to true — but it lives in module scope, not component state.
- StrictMode runs cleanup: the listeners are removed.
- Second mount: the guard is still true, because unmounting a component does not reset a module-level variable.
- Initialisation is skipped. The listeners are never re-attached. Nothing runs for the rest of the session.
let initialised = false; // module scope — survives unmount
export default function VisitorPing() {
useEffect(() => {
if (initialised) return; // ← blocks the StrictMode remount
initialised = true;
const onScroll = () => { /* … */ };
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return null;
}The cleanup is correct. The guard is correct in isolation. The combination is what fails, and only under a mount/cleanup/remount cycle you never see in production.
#Why it hides so well
This compounds with any subsystem that already fails quietly. In my case the write path returns false on failure rather than throwing, so a partial failure and a success are indistinguishable from the calling side. Two silent layers stacked on top of each other take a long time to see through.
#Telling it apart from a real bug
One check settles it. Build for production and run the built output — not next dev.
npm run build && npm startIf the feature works there and not in development, you are looking at a StrictMode interaction, not a bug in your logic. Do not spend the afternoon reading the logic.
#Two fixes
1. Move the guard inside the component
A useRef is re-created on the second mount, so the remount initialises normally. This is the right fix when the guard exists to protect against a double *run*, which is precisely what StrictMode is testing for.
const initialised = useRef(false);
useEffect(() => {
if (initialised.current) return;
initialised.current = true;
// …
}, []);2. Make the effect idempotent and drop the guard
Better still, write the effect so running it twice is harmless, and delete the guard entirely. addEventListener with a stable handler reference is already idempotent; a fetch that writes a row is not, and needs deduplication on the server side rather than a client-side flag that development will defeat.
#The rule I now follow
Any state that decides whether an effect has already run must live at the same lifetime as the effect. Module scope outlives the component; a ref does not. When those two lifetimes disagree, development and production disagree with them — and the environment that lies to you is the one you spend all day in.
Questions this answers
Why do my event listeners not work under React StrictMode in development?
StrictMode mounts, cleans up, and remounts each component. If your initialisation guard is stored outside the component — in module scope — it stays true through the cleanup, so the second mount skips initialisation and never re-attaches the listeners the cleanup removed. The listeners stay detached for the rest of the session.
How do I know if StrictMode is the cause and not my code?
Run a production build and test against that. StrictMode's double-mount only happens in development, so a feature that works in the built output and fails under the dev server is almost certainly hitting this rather than a logic bug.
Should I disable StrictMode to fix it?
No. The double-mount is a deliberate test for effects that are not safe to run twice, and disabling it hides the symptom while leaving the fragility in place. Move the guard into a useRef so it resets with the component, or make the effect idempotent and remove the guard entirely.
See also
- Learnings — The skill map — knowledge domains, competencies and credentials
- MIGI Agent Fleet — A 46-agent autonomous fleet with 500+ automated eval checks


