Skip to content

Bug: useEffect infinite loops are silent in production - NESTED_PASSIVE_UPDATE_LIMIT check is __DEV__-only and never throws #36423

@MainaJoseph

Description

@MainaJoseph

Infinite render loop detection silently degrades in production - useEffect
loops never throw, never trigger error boundaries, and are invisible to error
monitoring tools.

There are two related problems in throwIfInfiniteUpdateLoopDetected
(ReactFiberWorkLoop.js):

A) The NESTED_PASSIVE_UPDATE_LIMIT guard (passive effects / useEffect)
is wrapped entirely in if (__DEV__). In production it does not exist at all
the loop runs forever with no throw, no error boundary trigger, and no signal
to onUncaughtError.

B) The newer enableInfiniteRenderLoopDetection instrumentation path for
synchronous lanes (NESTED_UPDATE_SYNC_LANE / NESTED_UPDATE_PHASE_SPAWN)
also falls back to else if (__DEV__) { console.error(...) } when
enableInfiniteRenderLoopDetectionForceThrow is false. This makes the new
path weaker than the legacy path it sits alongside, which unconditionally
throws in production.

React version: 19.x (main as of commit 9635257, May 2026)

Steps To Reproduce

  1. Create a React app with a production build (NODE_ENV=production).
  2. Render the following component:
function BuggyComponent() {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    setCount(c => c + 1); // no dependency array - fires after every render
  });

  return <div>{count}</div>;
}

3. Mount <BuggyComponent /> inside a createRoot tree.
4. Observe CPU usage and the browser console.

Link to code example:
https://codesandbox.io/s/react-passive-infinite-loop-prod-repro

The current behavior

In development: After 50 passive updates, console.error is called with
"Maximum update depth exceeded...". No Error is thrown. No error boundary
is triggered. onUncaughtError / onCaughtError are never called.

In production: Nothing happens at all. The if (__DEV__) block is stripped
entirely. The component re-renders indefinitely, pegging a CPU core until the
browser kills the tab or the process OOMs. Error monitoring tools (Sentry,
Datadog, etc.) receive no signal because no Error object is ever created.

The relevant code in packages/react-reconciler/src/ReactFiberWorkLoop.js,
lines 5266–5278:

// Stripped entirely in production builds
if (__DEV__) {
  if (nestedPassiveUpdateCount > NESTED_PASSIVE_UPDATE_LIMIT) {
    nestedPassiveUpdateCount = 0;
    rootWithPassiveNestedUpdates = null;
    console.error( // ← warns but never throws
      'Maximum update depth exceeded. This can happen when a component ' +
        "calls setState inside useEffect, but useEffect either doesn't " +
        'have a dependency array, or one of the dependencies changes on ' +
        'every render.',
    );
  }
}

Compare with the synchronous lane guard at line 5257, which correctly throws
unconditionally in production:

// Throws in both dev and prod ✓
throw new Error(
  'Maximum update depth exceeded. This can happen when a component ' +
    'repeatedly calls setState inside componentWillUpdate or ' +
    'componentDidUpdate...',
);

The two guards are asymmetric. useEffect loops are held to a weaker standard
than componentDidUpdate loops despite causing identical real-world harm.

The expected behavior

A useEffect infinite loop should throw in both development and production,
matching the behavior of the synchronous NESTED_UPDATE_LIMIT guard.

Proposed fix for Problem A  promote the check out of __DEV__ and throw:

if (nestedPassiveUpdateCount > NESTED_PASSIVE_UPDATE_LIMIT) {
  nestedPassiveUpdateCount = 0;
  rootWithPassiveNestedUpdates = null;
  throw new Error(
    'Maximum update depth exceeded. This can happen when a component ' +
      "calls setState inside useEffect, but useEffect either doesn't " +
      'have a dependency array, or one of the dependencies changes on ' +
      'every render.',
  );
}

Throwing ensures:
- The nearest error boundary catches the error and renders its fallback UI.
- onUncaughtError / onCaughtError root options are invoked.
- Error monitoring tools receive a real Error object with a stack trace.
- Production behavior matches documented React guarantees.

For Problem B, the NESTED_UPDATE_SYNC_LANE and NESTED_UPDATE_PHASE_SPAWN
branches should also throw regardless of enableInfiniteRenderLoopDetectionForceThrow,
so the new instrumentation path is never weaker than the legacy path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions