React 18's StrictMode intentionally mounts every component twice in development — running your effect, its cleanup, and the effect again in immediate succession — specifically to catch effects that create resources without properly releasing them, and an observer hook that does not return a working cleanup function will double up its observe() calls as a result.

One-Sentence Answer

Doubled IntersectionObserver/ResizeObserver callbacks in development are caused by a missing or incomplete cleanup function in the effect that creates the observer; add return () => observer.disconnect(); and the doubling disappears in both development and production.

Problem / Scenario

A developer wraps new IntersectionObserver(callback, options) and observer.observe(el) inside a useEffect, ships it, and during local development notices the visibility callback firing twice for the same scroll event, or a console.log inside the callback printing every message twice. Production builds look fine. The instinct is to assume a browser quirk or a duplicate <script> tag, but the actual cause is React 18's StrictMode, which — only in development — deliberately simulates a component being mounted, unmounted, and remounted once immediately after the initial render, specifically to reveal effects whose cleanup functions are missing, incomplete, or do not fully release the resources the effect created.

This is the single most common defect the parent React Observer Hooks guide flags in code review, and it is fully preventable with the same disconnect()-based teardown discipline covered in Observer Lifecycle & Memory Management.

Mechanics

StrictMode's double-invocation is not a bug notification banner or console warning — it is an actual second execution of your component's render and effect functions. The sequence for a component wrapped in <StrictMode> during development is:

  1. Render the component.
  2. Run all effects (useEffect callbacks execute; your observer is created and observe() is called).
  3. Immediately run all effect cleanup functions as if the component were unmounting.
  4. Immediately re-run all effects again, as if the component were remounting.

If your effect's cleanup function correctly calls disconnect(), step 3 releases the first observer's native handle completely, and step 4 creates a fresh, independent observer. The net result after the synthetic cycle completes is exactly one live observer — indistinguishable from a production single mount. If the cleanup function is missing, only calls unobserve() for a subset of targets, or returns undefined, step 3 does nothing and step 4 creates a second observer alongside the still-live first one. Both observers now call your callback independently for every subsequent intersection change, producing the doubled invocation the developer sees in the console.

It helps to be precise about what "double" actually means here, because it is easy to mis-diagnose. The browser's own native IntersectionObserver implementation still delivers exactly one callback invocation per observer per batch of threshold crossings — the browser is not doing anything unusual. What has changed is that your component's effect created two separate observer instances, both registered against the same target element, and each one independently receives its own copy of the native notification. From the console's point of view this looks identical to "the callback ran twice," but the underlying cause is entirely in the React effect lifecycle, not in the Intersection Observer specification or the browser's scheduling behavior.

This same double-invocation applies to every effect in the component, not just the one creating the observer — event listeners added with addEventListener inside useEffect without a matching removeEventListener in cleanup exhibit an identical symptom, as do WebSocket connections, setInterval timers, and subscriptions to external stores. IntersectionObserver and ResizeObserver hooks are simply where teams notice it first, because the doubled console output from a visibility callback is immediately visible during everyday scrolling, whereas a doubled setInterval might just look like "the animation is a little faster than expected."

The diagram below traces both outcomes of the same synthetic mount-unmount-remount cycle side by side.

Strict Mode Synthetic Remount — With vs Without disconnect() in Cleanup Two horizontal sequences. The top sequence shows Mount, Effect creates Observer A, Synthetic cleanup calls disconnect on A, Effect recreates Observer B, ending in exactly one live observer. The bottom sequence shows the same steps but the synthetic cleanup does nothing, so Observer A stays alive alongside new Observer B, ending in two live observers both firing callbacks. Correct cleanup Missing cleanup Mount effect runs Observer A created Synthetic cleanup disconnect(A) Observer B created 1 live observer correct in dev & prod Mount effect runs Observer A created Synthetic cleanup no-op (A still alive) Observer B created 2 live observers callback fires twice Both sequences only occur in development under <StrictMode>. Production always mounts once.

Comparison: Effect Return Value Outcomes

Effect return value Cleanup on synthetic remount Result after Strict Mode cycle
undefined (no return statement) None Two live observers, doubled callbacks
() => {} (empty function) None Two live observers, doubled callbacks
() => observer.unobserve(el) Removes target, observer instance survives Two live observer instances, one has no targets — callback still doubles if a new el is later re-observed on the stale instance
() => observer.disconnect() Full teardown, instance released One live observer, correct in dev and production

Minimal Reproducible Example

TypeScript
// BUGGY — no cleanup function returned from the effect
import { useEffect, useRef } from 'react';

function BuggyVisibilityTracker(): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;

    const observer = new IntersectionObserver((entries) => {
      console.log('intersecting:', entries[0].isIntersecting); // fires twice per crossing in dev
    });

    observer.observe(ref.current);
    // BUG: no return statement — cleanup never runs, StrictMode's synthetic
    // unmount does nothing, and the remount creates a second live observer
  }, []);

  return <div ref={ref}>Track me</div>;
}

Under <StrictMode> in development, mounting BuggyVisibilityTracker once produces two IntersectionObserver instances, both observing the same element, both logging independently on every threshold crossing — a developer reading the console reasonably assumes the callback itself is broken, when the actual defect is the missing cleanup.

Production-Safe Solution

TypeScript
import { useEffect, useRef, useCallback } from 'react';

function FixedVisibilityTracker(): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);

  const handleIntersect = useCallback<IntersectionObserverCallback>((entries) => {
    console.log('intersecting:', entries[0].isIntersecting);
  }, []);

  useEffect(() => {
    if (typeof IntersectionObserver === 'undefined' || !ref.current) return;

    const observer = new IntersectionObserver(handleIntersect, { threshold: 0.1 });
    observer.observe(ref.current);

    // Cleanup — runs on StrictMode's synthetic unmount AND on the real unmount.
    // This is the only line separating "double-fires in dev only" from
    // "actually leaks observers in production".
    return () => observer.disconnect();
  }, [handleIntersect]);

  return <div ref={ref}>Track me</div>;
}

For hooks that must survive a remount whose ref target changes identity (not just the StrictMode synthetic cycle, but a genuine swap of the observed DOM node), pair the cleanup with the ref-callback pattern from Building a Reusable useIntersectionObserver Hook in TypeScript so the effect's dependency array includes the tracked node itself, not just an empty array:

TypeScript
// Ref-callback variant — correct even when the tracked node changes
import { useEffect, useState, useCallback } from 'react';

function useVisible(): [(el: Element | null) => void, boolean] {
  const [node, setNode] = useState<Element | null>(null);
  const [visible, setVisible] = useState(false);
  const setRef = useCallback((el: Element | null) => setNode(el), []);

  useEffect(() => {
    if (typeof IntersectionObserver === 'undefined' || !node) return;

    // Idempotency guard: if this effect somehow runs twice for the same node
    // before cleanup fires, a WeakSet check prevents a duplicate observe() call.
    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), {
      threshold: 0.1,
    });
    observer.observe(node);

    return () => {
      observer.disconnect(); // authoritative teardown, not just unobserve(node)
    };
  }, [node]);

  return [setRef, visible];
}

Idempotency guard for extra safety. If your observer setup has side effects beyond observe() — for example, incrementing an external counter or logging to an analytics pipeline — add a module-level or ref-based guard so a hypothetical double-run (from a future concurrent rendering feature, not just today's StrictMode) cannot double-count:

TypeScript
useEffect(() => {
  if (!ref.current) return;
  let disposed = false;

  const observer = new IntersectionObserver((entries) => {
    if (disposed) return; // guard against any late-firing callback post-disconnect
    handleIntersect(entries);
  });
  observer.observe(ref.current);

  return () => {
    disposed = true;
    observer.disconnect();
  };
}, [handleIntersect]);

Verification Steps

  • Reproduce with <StrictMode> explicitly wrapping the component (it is on by default in create-react-app and Next.js App Router dev mode, but confirm) and add console.count('observer created') inside the effect body.
  • Count observer creations across one mount. With the buggy version, the count reaches 2 and stays there. With the fixed version, the count also reaches 2 during the synthetic cycle (this is expected and correct — StrictMode always runs the effect body twice) but the first observer is fully disconnected before the second is created, so only one is ever live at a time.
  • Confirm only one observer is live by adding a disconnect spy: wrap the global IntersectionObserver constructor in a test double that increments a counter on observe() and decrements it on disconnect(). After the synthetic StrictMode cycle settles, the counter should read exactly 1.
  • Build and run the production bundle (next build && next start, or vite build && vite preview) and confirm the callback fires exactly once per threshold crossing — proving the doubling was a StrictMode dev-mode artifact and not a genuine defect.
  • Heap-snapshot check for real leaks. If the doubled behavior persists in the production build, it is not StrictMode — follow the heap-snapshot workflow in Preventing Memory Leaks in Long-Running Observers to locate the actual retained observer.

Common Mistakes

  • Removing <StrictMode> to "fix" the doubling. This hides the detector without fixing the underlying non-idempotent effect, and the same bug will resurface under future React concurrent features that remount components without a full page reload.
  • Calling unobserve() in cleanup instead of disconnect(). unobserve() only removes the specific target from that observer instance; the instance itself, and its callback closure, remain alive and registered. disconnect() is the atomic, authoritative teardown.
  • Assuming the doubled callback means the browser fired the intersection event twice. The browser only fired once; two separate IntersectionObserver instances each independently received and processed that one native event.
  • Debugging by adding a manual debounce around the callback. This masks the symptom (fewer visible log lines) without removing the second observer instance, which still holds native resources and will eventually cause a real memory leak if the pattern is repeated across many components.
  • Forgetting that useCallback without a dependency array recreates the callback every render, which — if the callback itself is in the effect's dependency array — causes the effect to rerun on every render, independent of and compounding with the StrictMode double-invoke.
  • Blaming a third-party observer library before checking your own cleanup path. Popular community hooks (react-intersection-observer, react-use) already implement the disconnect discipline described here — if doubling appears with one of these libraries, the far more likely cause is a custom wrapper your own codebase added around the library's hook, not a defect in the library itself.
  • Testing only in the browser and never checking the terminal for React's own StrictMode warnings. React logs a console warning distinguishing "Warning: An effect function must not return anything besides a function, which is used for clean-up" from silent doubling — read the full console output, not just your own console.log lines, before concluding the cause is unclear.

Why This Matters Beyond IntersectionObserver

The same disconnect-in-cleanup discipline generalizes to ResizeObserver and MutationObserver hooks without any changes to the diagnostic approach — replace observer.disconnect() with the equivalent teardown call for whichever Observer type the hook wraps, and the StrictMode synthetic remount behaves identically. Teams building a shared internal hooks package should write exactly one regression test that mounts a component under <StrictMode>, captures the number of live observer instances after the synthetic cycle settles, and asserts it equals 1 — this single test catches the missing-cleanup defect for every hook in the package, present and future, without needing a bespoke test per hook.

FAQ

Does Strict Mode's double-invocation happen in production builds?

No. The mount-unmount-remount sequence Strict Mode performs on effects only runs in development builds when StrictMode is active and React detects it is running outside of production mode. Production builds (created with a bundler's production configuration) skip this synthetic double-invocation entirely — components mount exactly once.

Can I disable Strict Mode to make the doubled callbacks go away?

You can, but you should not, because removing StrictMode only hides the symptom in your own environment while leaving the underlying non-idempotent effect in place. Any future React feature that remounts components without a full page reload (offscreen rendering, activity boundaries) will trigger the same bug in production. Fix the cleanup function instead of removing the detector.

How do I tell a real memory leak apart from Strict Mode's dev-only double-invoke?

Build a production bundle and run it (next build && next start, or vite build && vite preview) and repeat the mount/unmount cycle. Strict Mode's double-invoke only happens in the development server; if doubled callbacks or growing observer counts persist in the production build, it is a genuine leak — most commonly a missing disconnect() in the effect's cleanup function — not a Strict Mode artifact.


↑ Back to React Observer Hooks