Swil
Frontend
InternalsJUL 25, 2026

StrictMode Runs Your Effect Twice On Purpose

The double-invoke is an assertion, not a nuisance: cleanup must undo setup — including the bookkeeping.

Your effect runs twice in development and once in production, so the usual reaction is to make the second run go away — a ref, a module flag, a “have I already started?” guard. That guard is almost always a new bug wearing the old one’s clothes. StrictMode is not causing a problem; it is running an assertion, and the assertion just failed.

Liveswitch between production and StrictMode
useEffect(() => {
  if (startedRef.current) return;   // "only fetch once"
  startedRef.current = true;
  const controller = new AbortController();
  load(controller.signal);
  return () => controller.abort();  // latch is never released
}, [enabled]);
  1. setupacquire startedRef · acquire requeststartedRef=1 request=1
  2. cleanuprelease requeststartedRef=1 request=0
  3. setupsetup skipped — the latch is still heldstartedRef=1 request=0
Deadlock — the effect can never run again

Nothing leaks and nothing errors — the work simply never happens. This only breaks in development, which is exactly where you are looking.

The claim StrictMode is checking is narrow and precise: mounting twice must leave the world exactly as mounting once would. If cleanup is a true inverse of setup, the extra cycle is unobservable and you will never notice it. If it is not, you have a bug that production will find later — on a route remount, a fast refresh, a Suspense replay, or an offscreen tab being restored. Switch the toggle above to Production and watch the failing presets go quiet. That silence is the point: production is where the bug hides, not where it is absent.

Two failure shapes, and only one of them is loud

The famous one is the leak: setup subscribes, cleanup forgets to unsubscribe, so after the double-invoke you hold two subscriptions, two intervals, two sockets. It is easy to spot because something happens twice — duplicate events, doubled counters, two identical requests.

The other one is silent, and it is the one the guard creates. Call it the deadlock: setup sets a latch so it only ever runs once; cleanup tears down the work but never clears the latch. The second setup sees the latch, returns early, and does nothing. Nothing leaks. Nothing errors. The feature is simply dead, and it is dead only in development, which is the worst possible place for a bug to live, because that is where you are looking.

The rule that dissolves it

A latch is not automatically wrong — “fetch this once per session” is a real requirement. What is wrong is a latch whose lifetime is longer than the thing it guards. If the work was completed, the latch should survive; if the work was cancelled, the latch must be released, or nothing can ever start it again. The fourth preset is that one-line difference.

The more general version: cleanup should undo whatever setup did, including the bookkeeping. If setup wrote a flag, cleanup owns that flag too. It is easy to remember the socket and forget the boolean, because the boolean does not feel like a resource. To the effect, it is.

What not to do

Disabling StrictMode to make the symptom disappear trades a visible development bug for an invisible production one. The same goes for a module-level hasRun that survives unmount entirely: it makes the second invoke quiet, and it makes every future remount quiet too. If the effect genuinely must not repeat, the state that says so belongs somewhere with a matching lifetime — a cache keyed by input, a store outside the component — not in a flag that only cleanup could have reset and didn’t.

The ledger

The verdicts in the demo are not a lookup table. They come from the module below, which runs a spec through a phase sequence and reports what the world holds afterwards. isStrictModeSafe is the assertion itself, written out: run once, run twice, compare.

effectLedger.ts
/**
 * What React's StrictMode double-invoke is actually testing.
 *
 * In development, StrictMode runs every effect as setup → cleanup → setup. This
 * is not a simulation of anything users do; it is an assertion. It claims that
 * cleanup is a true inverse of setup, so that mounting twice leaves the world
 * in exactly the state mounting once would. If that holds, the extra cycle is
 * invisible. If it does not, the effect had a bug that would eventually show up
 * in production too — on a remount, a fast-refresh, or a Suspense replay.
 *
 * Two failure shapes account for almost all of them, and they look nothing
 * alike from the outside:
 *
 *   LEAK     setup acquires something, cleanup does not release it.
 *            → after StrictMode you hold two subscriptions / timers / sockets.
 *
 *   DEADLOCK setup sets a guard so it only ever runs once, cleanup does not
 *            clear it. The second setup no-ops and the work never happens.
 *            → nothing leaks; the feature is simply, silently dead.
 *
 * The ledger below models both without React, so the demo on this page and the
 * unit tests reason about the same thing.
 */

export type ResourceOp = { op: "acquire" | "release"; id: string };

export interface EffectSpec {
  /**
   * A guard consulted at the top of setup ("have I already started?"). Setup
   * no-ops when it is held. Modelled as a resource so cleanup can release it —
   * or, in the buggy version, forget to.
   */
  latch?: string;
  setup: ResourceOp[];
  cleanup: ResourceOp[];
}

export type Phase = "setup" | "cleanup";

export interface LedgerStep {
  phase: Phase;
  /** True when a latch caused setup to do nothing. */
  skipped: boolean;
  applied: ResourceOp[];
  /** Held count per resource *after* this step. */
  balance: Record<string, number>;
}

export type Verdict = "ok" | "leak" | "deadlock";

export interface LedgerResult {
  steps: LedgerStep[];
  balance: Record<string, number>;
  /** Resources held more than once — the classic double-subscription. */
  leaks: string[];
  /** True when a latch blocked a setup that should have re-run. */
  deadlocked: boolean;
  verdict: Verdict;
}

const held = (balance: Record<string, number>, id: string): number => balance[id] ?? 0;

function apply(balance: Record<string, number>, ops: readonly ResourceOp[]): ResourceOp[] {
  for (const { op, id } of ops) {
    // Clamped at zero: releasing something you never acquired is a no-op, not
    // a negative balance. Modelling it as -1 would invent a failure mode React
    // does not have.
    balance[id] = Math.max(0, held(balance, id) + (op === "acquire" ? 1 : -1));
  }
  return [...ops];
}

/**
 * Run an effect through a phase sequence and report what the world holds after.
 *
 * @param phases `["setup"]` for production, `["setup","cleanup","setup"]` for
 *               StrictMode. Any sequence is allowed — remounts are just longer.
 */
export function runLifecycle(spec: EffectSpec, phases: readonly Phase[]): LedgerResult {
  const balance: Record<string, number> = {};
  const steps: LedgerStep[] = [];
  let deadlocked = false;

  for (const phase of phases) {
    if (phase === "setup") {
      const blocked = spec.latch !== undefined && held(balance, spec.latch) > 0;
      if (blocked) {
        // A latch is only correct if it is meant to survive; when it blocks a
        // setup that follows a cleanup, the effect can never run again.
        deadlocked = true;
        steps.push({ phase, skipped: true, applied: [], balance: { ...balance } });
        continue;
      }
      const ops: ResourceOp[] = spec.latch
        ? [{ op: "acquire", id: spec.latch }, ...spec.setup]
        : [...spec.setup];
      steps.push({ phase, skipped: false, applied: apply(balance, ops), balance: { ...balance } });
    } else {
      steps.push({
        phase,
        skipped: false,
        applied: apply(balance, spec.cleanup),
        balance: { ...balance },
      });
    }
  }

  const leaks = Object.keys(balance).filter((id) => balance[id] > 1);
  const verdict: Verdict = deadlocked ? "deadlock" : leaks.length > 0 ? "leak" : "ok";

  return { steps, balance, leaks, deadlocked, verdict };
}

export const PRODUCTION_PHASES: readonly Phase[] = ["setup"];
export const STRICT_MODE_PHASES: readonly Phase[] = ["setup", "cleanup", "setup"];

/**
 * The property StrictMode is asserting: mounting twice must leave the same
 * world as mounting once. An effect passes when both runs agree.
 */
export function isStrictModeSafe(spec: EffectSpec): boolean {
  const once = runLifecycle(spec, PRODUCTION_PHASES);
  const twice = runLifecycle(spec, STRICT_MODE_PHASES);
  if (twice.verdict !== "ok") return false;
  const ids = new Set([...Object.keys(once.balance), ...Object.keys(twice.balance)]);
  for (const id of ids) {
    if (held(once.balance, id) !== held(twice.balance, id)) return false;
  }
  return true;
}