Bug: hydration restart storm — a root that suspends during hydration re-renders thousands of times when `useDeferredValue(value, initialValue)` is mounted
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Aptitud para principiantes
- 35/100
- Tipo de issue
- Error
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- javascript, react
- Área
- frontend, performance
Línea de trabajo
Reproduce the inline JSX case with the provided esbuild command and inspect the hydration path through mountDeferredValueImpl, renderDidSuspendDelayIfPossible, markRootSuspended, markSpawnedDeferredLane, and markRootFinished. Compare it with the no-initialValue, Suspense, and createRoot variants described in the issue. Done means root hydration waits for the lazy ping without repeated empty deferred-lane commits, while hydration still completes.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
React version: 19.3.0 (latest). Also reproduced on 19.2.8 and on 19.3.0-canary-cbb046ab-20260731 (the copy vendored in Next.js 16.3.4). The relevant functions (markSpawnedDeferredLane, markRootSuspended, markRootFinished, mountDeferredValueImpl) are unchanged on main as of 2026-09-24.
Steps To Reproduce
- Bundle the code below:
esbuild repro.jsx --bundle --jsx=automatic --define:process.env.NODE_ENV='"production"' --outfile=bundle.js. - Open the HTML below, then read
window.__counter.appafter about 1.8 s.
Link to code example: inline, with no dependencies other than React.
import { useDeferredValue, lazy, startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
const counter = (window.__counter = { app: 0 })
function Real() { return <i>real</i> }
// A lazy element type whose module arrives after hydration starts (like a Flight client reference)
const Lazy = lazy(() => new Promise((r) => setTimeout(() => r({ default: Real }), 1000)))
function App() {
counter.app++
const v = useDeferredValue('value', 'value') // same shape as Next's layout-router during hydration
return <div><span>{v}</span><Lazy /></div>
}
startTransition(() => hydrateRoot(document.getElementById('root'), <App />))
<div id="root"><div><span>value</span><i>real</i></div></div>
<script src="bundle.js"></script>
The current behavior
Suppose a hydration render suspends outside any Suspense boundary, and the tree contains a useDeferredValue with an initialValue. React then re-renders the root in a tight loop until the thenable resolves. In a 1-second window this means ~80,000–150,000 renders in Chromium and ~7,000–24,000 in WebKit, where 2 are expected.
No error is reported, and hydration completes normally when the thenable resolves. The cost is CPU time spent spinning while the page is still loading.
This affects every Next.js App Router page on a cold load. app-router.js and layout-router.js call useDeferredValue(x, resolvedPrefetchRsc) during hydration. The Flight client references for the root/app layouts are lazy element types whose chunks usually arrive after hydration has started. We measured 13k–17k root restarts per cold page load on a real app at 150 ms RTT (details below).
Headless Playwright 1.62.1 (Chromium 1234 / WebKit 2336), macOS, 3 loads each:
| variant | Chromium App renders |
WebKit App renders |
|---|---|---|
useDeferredValue(v, initial), startTransition(hydrateRoot) |
91,479 – 97,461 (19.3.0); 92,938 – 102,677 (19.2.8); 78,678 – 150,835 (canary) | 9,559 – 9,667 (19.3.0); 7,009 – 8,204 (19.2.8); 7,226 – 8,110 (canary) |
same, hydrateRoot without startTransition |
139,120 – 142,916 (19.3.0); 125,311 – 141,163 (19.2.8); 104,954 – 136,051 (canary) | 14,767 – 22,008 (19.3.0); 11,996 – 12,965 (19.2.8); 11,648 – 13,990 (canary) |
useDeferredValue(v) (no initial value) |
2 | 2 |
<Lazy /> wrapped in <Suspense> (server HTML with <!--$-->…<!--/$-->) |
2 | 2 |
no hydration: createRoot + startTransition(() => root.render(<App />)), same tree |
4 | 4 |
The last three rows are identical on 19.3.0, 19.2.8 and the canary. In every variant the real content is rendered once the lazy resolves (Real renders once), and there are no recoverable errors.
The 19.3.0 rows were measured in a later window, interleaved with 19.2.8. In that same window 19.2.8 gave 91,733–95,850 (Chromium) and 9,746–11,611 (WebKit) for the first row, and 131,964–142,750 / 20,619–24,015 for the second. Absolute counts vary with machine load, so compare within a window. The code above, run verbatim against 19.3.0, gives 95,682 – 103,096 renders in Chromium and 7,655 – 8,030 in WebKit.
What we think happens
We instrumented a production build (the Next.js-vendored canary). We counted performWorkOnRoot, prepareFreshStack, commits by lane, and each call site of markRootSuspended / markSpawnedDeferredLane. Each iteration goes like this:
- The hydration render (TransitionHydrationLane, 128) mounts
useDeferredValue(value, initialValue).mountDeferredValueImplcallsrequestDeferredLane(), which gives D, a TransitionDeferredLane (262144 → 524288 → 1048576 → 2097152, cycling). It then callsmarkSkippedUpdateLanes(D). - The render suspends on the lazy (
SuspendedOnImmediate→SuspendedAndReadyToContinue→ unwind to the root; there is no boundary).renderDidSuspendDelayIfPossible()finds non-idleworkInProgressRootSkippedLanes, which contain only D, the lane this same render just spawned. It callsmarkRootSuspended(root, 128, D), following the comment "Mark the current render as suspended so that we switch to working on the updates that were skipped".finishConcurrentRender(RootSuspendedWithDelay) callsmarkRootSuspended(root, 128, D)a second time. Both calls end inmarkSpawnedDeferredLane(root, D, 128), which makes D pending. markSpawnedDeferredLaneentangles D withentangledLanes & UpdateLanes.UpdateLanesdoes not include the hydration lanes, so D is not entangled with 128. For an ordinary transition update the suspended lane is entangled and re-rendered together with D. The same tree rendered withcreateRootin a transition finishes in 4 renders (see the table), so the loop is specific to hydration.- D renders on its own. The root is still dehydrated, and no fiber in the current tree carries D, so this is a bailout. D commits an empty tree:
finishedWork.child === nullon every one of these commits. markRootFinishedrunsroot.suspendedLanes = NoLanes("Let's try everything again"). Lane 128 is pending again,ensureRootIsScheduledpicks it, and the cycle repeats from step 1 with a fresh deferred lane.
Counters from one cold load of a real Next.js page, with the Flight chunk held for 1.5 s:
- 44,952 root hydration entries.
- 44,966 commits, of which 44,951 were empty commits on the four TransitionDeferredLanes (evenly split).
- 89,905
prepareFreshStackcalls: 44,952 for lane 128 and 44,951 for D. - 89,904
markSpawnedDeferredLanecalls.
A separate load split the markRootSuspended calls by call site. It had 17,794 root entries, 17,793 calls from renderDidSuspendDelayIfPossible (skipped lanes = D every time) and 17,795 from finishConcurrentRender. There were only 2 markSpawnedDeferredLane calls from markRootFinished.
Causal check. In the same build, we suppressed only the markSpawnedDeferredLane call from markRootSuspended when the suspended lanes include 128, controlled by a runtime flag, with loads interleaved. Root hydration entries dropped from 13,023–16,793 to 4 per load. Hydration still completed at the same point, about 10 ms after the last client chunk arrived, in both arms.
Impact (real app, Next.js 16.3.4, cold cache, Chromium, emulated 150 ms RTT / 9 Mbps, n=5 per arm)
| default | spawn suppressed in hydration | |
|---|---|---|
| root hydration restarts | 15,612 (13,023–16,793) | 4 |
main-thread task time (CDP TaskDuration) |
940 ms (870–1104) | 310 ms (242–1438) |
script time (ScriptDuration) |
610 ms (602–649) | 78 ms (66–234) |
| hydration commit after last client chunk | 10 ms | 9 ms |
With 4× CPU throttling we saw 764 vs 4 restarts, and 771 vs 440 ms script time. With a warm HTTP cache (chunks already cached) the storm does not happen: 1 root entry.
The expected behavior
A hydration render that suspends at the root should wait for the ping, as it does without initialValue or inside a Suspense boundary. A deferred lane spawned by a render that never committed should not cause the suspended hydration lane to be retried immediately.
Possible directions, which you know better than we do:
- In
renderDidSuspendDelayIfPossible, don't treat the render's own spawned deferred lane as a "skipped update that might unblock this render". - Entangle the spawned lane with hydration lanes as well.
- Don't clear
suspendedLanesfor a commit that completed no work.
- Lenguaje dominante
- JavaScript
- Estrellas
- 251k
- Forks
- 51.4k
- Merge medio
- 1 d 20 h
- PR fusionados (30 d)
- 39
Preparar el entorno
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de react/react
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
react/react#37669 · 1 reacción ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
Los mantenedores suelen responder en 1 día
Todos los issues de react/react
Issues similares
-
Design only Leadership Survey SLFS
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
bcgov/digital-journeys#2293 ·
-
Toolkit
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
Los mantenedores suelen responder en 1 día
-
API Bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
ProjectSidewalk/SidewalkWebpage#5556 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
jessepollak/home#1454 ·
Los mantenedores suelen responder en 1 día
-
Mend: dependency security vulnerability untriaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 64/100
opensearch-project/OpenSearch-Dashboards#12822 ·
Los mantenedores suelen responder en 1 día