One component tree is rendered three times by the RSC runtime. The useful distinction is which branches resolve, which remain suspended, and what each pass emits.
cached layoutcached async workrequest-time RSC work
cached frame
CachedLayout
Produces the document frame, layout timestamp, and navigation. Its cached output is reusable, but its children remain a temporary-reference slot filled by the surrounding render.
children slot JSX children travel as temporary references, so the layout cache stores a reusable frame with placeholders instead of baking these child elements into the cache entry.
dynamic child
DynamicContent
markDynamic() leaves this Suspense branch pending during prerender. Its fallback becomes part of the static HTML shell.
cached async
CachedAsyncContent
The 100 ms miss is useful static work. Warmup waits for and stores it, so the final shell contains its resolved output.
Cache serialization is an existing primitive, not the PPR mechanism. The dashed child slot is the cache component temporary-reference hole: non-serializable JSX children are passed through as references rather than becoming the cached layout value. PPR adds readiness tracking for cache fills and uses markDynamic to identify the request-time hole inside that slot.
The PPR roles in root.tsx
const CachedLayout = createCachedComponent(Layout)
const CachedAsyncContent = createCachedComponent(AsyncContent)async function DynamicContent({ url }) {
await markDynamic() // hangs only while prerender context is active
await delay(300) // request-time work
return <pre>Requested URL: {url.pathname}</pre>
}
The similarly named APIs operate on different stream types. The RSC layer produces Flight; the React DOM layer turns decoded Flight into prerendered or resumed HTML.
RSC · Flight
react-server-dom family
Use the Vite-aware public wrappers so client references are resolved through the Vite manifest.
React DOM owns serializable postponed state and the operation that resumes it.
Build timereact-dom/static.edge prerender() → HTML prelude + postponed
Request timereact-dom/server.edge resume() → HTML continuation
The bridge:@vitejs/plugin-rsc/ssrcreateFromReadableStream() decodes either Flight stream into the React tree consumed by React DOM. RSC prerender() and React DOM prerender() share a name, but only the latter returns postponed state for resume().
All three branches resolve; emit complete fresh Flight.
CachedLayoutrestored cache
Cache hit → resolvedThe persisted frame is replayed.
DynamicContentlive request
Temporary 300 ms suspension → resolvedNo prerender context means markDynamic() does not create the hanging suspension.
CachedAsyncContentrestored async cache
Cache hit → resolvedThe warmup value and timestamp are reused.
The second pass is demo scaffolding, not a requirement. Because the readiness gate waits for every discovered cache fill to settle and gives React a retry turn before cutoff, the warmup already emits a valid partial Flight prelude that could feed React DOM directly, so the restart is not strictly necessary here. The demo still discards it and renders again so that cache discovery and shell capture read as separate stages, which mirrors production frameworks whose discovery pass genuinely lacks final-render semantics.
The choice of prerender() is incidental too. The warmup could instead drain a plain renderToReadableStream(), and a partial prelude can also come from a controlled renderToReadableStream() cutoff. Next.js and vinext even pick opposite arrangements, so Next.js produces its final RSC with renderToReadableStream() under staged cutoff, while vinext warms with renderToReadableStream() and captures its final Flight with prerender(). What is fundamental is the phased control (readiness, an intentional dynamic hole, and a cutoff), not the number of passes or the specific stream API.
React DOM receives the component states emitted by the corresponding RSC render. The repeated blocks below connect each RSC output directly to its HTML operation.
Final RSC prerender output · partial Flight
CachedLayoutresolved
DynamicContentpending until cutoff
CachedAsyncContentresolved
react-dom/static.edgeprerender() receives the decoded partial Flight tree
HTML build resultprelude + serializable postponed state
Request RSC render output · fresh Flight
CachedLayoutresolved
DynamicContentsuspends, then resolves
CachedAsyncContentresolved
react-dom/server.edgeresume() receives the decoded request tree + persisted postponed
HTML continuationThe request-time content that completes the persisted shell.
The two partial HTML streams become one response
postponed tells resume() where to continue. It is not itself written into the response.
Request-time continuationresumed HTML for dynamic content
response begins immediately → continuation bytes follow and complete the postponed hole
@vitejs/plugin-rsc/ssrcreateFromReadableStream() is the bridge in both rows: it decodes Flight into the React tree consumed by React DOM.
Local investigation artifact. Source claims verified against packages/plugin-rsc/examples/ppr at 3ec1aa8f7d165150d4ec80d8b0cce365a41293b8. No external assets or runtime JavaScript.