A render loop nobody’s guard catches

One line in a small floating widget starts a self-sustaining re-render loop, paced by requestAnimationFrame. Every renderer below runs it happily — no error, no jank, and no warning from any framework.

What it costs depends on the renderer underneath. On Vue 3, Svelte 5, and React in legacy sync mode it is pure waste: ~60 renders a second, forever, and nothing breaks. On React with createRoot the same loop deadlocks an unrelated screen permanently — spinner forever, idle network tab, no error, reload the only escape. So this is not a React bug with a React fix; it is an ordinary feedback loop whose blast radius is enormous on a preemptive scheduler, and React is the one shipping preemptive scheduling today.

De-identified from a production single-page app, where it arrived as user reports of "the page just keeps loading" on deep links. Every demo below is live; the counters at the top of each page are the measurement.

Keep the demo tab in the foreground. The loop is paced by requestAnimationFrame, and a hidden tab gets no animation frames — so a background tab cannot reproduce the bug at all. This cost us three false readings while investigating.

Try it

React 19 · concurrent

The bug deadlocks Ref re-attach answered with a render. Watch the panel never arrive. Same, without any refs deadlocks A plain setState per frame. The loop's source is irrelevant. The bug, fixed healthy One coalescing guard inside the existing rAF callback. No loop at all healthy The control.

React 18 · the decisive comparison

createRoot — concurrent deadlocks Same code, same loop, priority-based scheduling. ReactDOM.render — legacy sync wasteful but fine The loop still burns a render every frame; the screen paints anyway.

Other renderers · same 60Hz update stream

Vue 3 — async setup + <Suspense> wasteful but fine Nothing goes wrong. Nothing warns either. Svelte 5 — {#await} wasteful but fine Same: no failure, no warning.
What to watch on every page: panel commits and request started in effect. In the deadlocked cases the panel is attempted hundreds of times and committed zero times, and the follow-up request is never issued at all — the network tab is idle, not blocked.

The mechanism

  1. A positioning controller re-renders its consumer whenever its ref callback is called with a different value, scheduling that render through requestAnimationFrame.
  2. Component libraries compose refs and build a fresh ref callback on every render. React then does what the spec says: call the old callback with null, the new one with the node. The ref is re-attached on every commit, though the node never changed.
  3. Both legs of that re-attach miss the "did it change?" guard, so every commit schedules another render, which produces another commit. requestAnimationFrame never breaks the loop — it only paces it at one render per frame, which is exactly why it looks harmless.
  4. Meanwhile a Suspense boundary suspends before its first commit (a deep link straight into a data-gated screen). When its promise resolves, React schedules a retry render: the lowest priority it has, and one it deliberately never expires.
  5. Every retry render is interrupted by the next frame's update and restarted from the root. If the subtree takes longer to render than the gap between updates, it never finishes.
  6. The subtree never commits, so its effects never run — and libraries that subscribe on mount (React Query subscribes its observer in a commit effect) therefore never issue a request. Spinner forever, idle network, no error, reload is the only escape.

The missing request is a consequence, not the cause. That inversion is most of why this takes so long to find.

Measured

One machine, Chrome, foreground tab, 8000-node subtree. "Storm" is the permanent ~60Hz update stream.

Renderer Storm Stream source Subtree commits Follow-up request Warnings
React 19 createRoot 60/s ref re-attach 0 of 504 never issued 0 / 0
React 19 createRoot 60/s plain setState 0 of 471 never issued 0 / 0
React 19, loop fixed 0/s 1 of 3 589 ms 0 / 0
React 18 createRoot 60/s ref re-attach 0 of 462 never issued 0 / 0
React 18 ReactDOM.render 60/s ref re-attach 1 of 2 550 ms 0 / 0
Vue 3 60/s reactive write 1 of 1 810 ms 0 / 0
Svelte 5 60/s $state write 1 of 1 570 ms 0 / 0

Those numbers come from development builds at 8000 cheap leaves. This site ships production builds, which render far faster per component, so its default subtree is 4000 leaves × 4000 iterations of work each. Every row was re-verified on the exact build deployed here.

The loop is not React's — it is a universal feedback-loop bug, and Vue and Svelte run it just as happily. The deadlock is concurrent React's: same version, same code, createRoot hangs and ReactDOM.render does not. And the ref bug is not required — any unstoppable ~60Hz default-priority update does it.

Read the Vue and Svelte rows carefully: nothing went wrong there. That is not those renderers handling the bug better — they did not notice it either. They simply have no lowest-priority retry lane for the stream to starve, so the same defect can only express itself as burnt CPU. Which is why a bug like this survives in production: without a Suspense boundary to trip over, it is invisible.

Nobody's circuit breaker fires

Renderer Guard it ships Fired here?
React Too many re-renders no — covers synchronous render-phase loops only
Vue 3 Maximum recursive updates exceeded no
Svelte 5 effect_update_depth_exceeded no

They all detect recursion inside one tick. A loop paced by requestAnimationFrame is not recursion: each turn is one legitimate update, one frame apart, and every guard stays happy. That gap is common to all of them — not a React omission.

The fix

  function scheduleNotify() {
    cancelAnimationFrame(rafId);
    rafId = requestAnimationFrame(() => {
+     // A ref re-attach detaches and re-attaches within one frame, so by the
+     // time this callback runs the target is back to what we last notified
+     // about. Only a real change gets through.
+     if (elState.target === notifiedTarget) return;
+     notifiedTarget = elState.target;
      notify?.();
    });
  }

requestAnimationFrame was already there, but only as a pacer. This turns it into a real debounce: coalesce whatever happened during the frame, then compare against what was last announced. Real element changes still notify; a detach/attach pair does not.

In the original app that took commits from 115 per 2 s down to 5 per 2 s, and the stall went from roughly one load in two to ten clean loads out of ten with the Suspense code untouched.

Who is to blame

Any code that re-renders in response to a ref attach has to be idempotent with respect to re-attaches. The second-order lesson is on the Suspense side: a subtree whose first paint depends on a suspense-style hook has only the lowest-priority lane to get itself committed — fine in a quiet app, fatal next to a permanent update stream.