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.
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
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
setup + <Suspense>
wasteful but fine
Nothing goes wrong. Nothing warns either.
Svelte 5 — {#await}
wasteful but fine
Same: no failure, no warning.
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
-
A positioning controller re-renders its consumer whenever its
refcallback is called with a different value, scheduling that render throughrequestAnimationFrame. -
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. -
Both legs of that re-attach miss the "did it change?" guard, so every commit schedules
another render, which produces another commit.
requestAnimationFramenever breaks the loop — it only paces it at one render per frame, which is exactly why it looks harmless. -
Meanwhile a
Suspenseboundary 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. - 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.
- 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
- React — behaving to spec. A ref callback with a new identity is a new ref.
- The component library — a fresh composed ref per render is wasteful but legal, and long-standing. It cannot know a consumer will re-render in response.
- The controller — the actual bug, two mistakes stacked: treating "my ref was called with a different value" as "the element changed", and answering a ref call with a render, so the answer recreates the question.
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.