Streaming internals

Estimated reading time: 2 minutes

Initial response

When renderToStream produces the initial HTML, each Suspense boundary renders its fallback content wrapped in a <div> with a data-sf attribute and a unique ID derived from the request ID and the boundary's run number.

<div id="B:req-1:1" data-sf>Loading...</div>

This div is part of the initial HTML chunk sent to the client. The browser renders it immediately.

Async resolution

When an async child resolves, the runtime pushes two elements to the stream: a <template> containing the resolved HTML and a <script> that triggers the replacement.

<template id="N:req-1:1" data-sr><div>Actual content</div></template><script id="S:req-1:1" data-ss>$KITA_RC("req-1:1")</script>

The <template> is inert, so the browser does not render its content. The <script> executes immediately when the browser receives it, calling the replacement function.

Client-side replacement

The $KITA_RC function is a small script injected before the first Suspense result. It finds the fallback div by ID, extracts the template's content into a document fragment, and replaces the div with the fragment. It then removes the template and script elements from the DOM.

After each replacement, the function scans for any pending templates whose fallback divs were not yet in the DOM when they arrived. This handles cases where a fast async boundary resolves before the initial HTML containing its fallback has been parsed.

HTTP transfer

The response uses chunked transfer encoding. The initial HTML is the first chunk. Each resolved Suspense boundary produces an additional chunk. The response has no Content-Length header, so the connection remains open until the last boundary resolves and the stream is closed.

Framework integrations capture Suspense request state before awaiting an asynchronous root. This keeps replacement chunks available when a fast boundary resolves before a slower root sibling. The resolved root is queued before those buffered replacements, so every replacement follows the fallback it targets.

Express and Fastify integrations also release captured request state when the client disconnects. User promises continue independently, but later settlements cannot write to the closed stream or delete state belonging to another request.

Runtimes like Node.js and Bun handle chunked encoding automatically when data is written to the response without a Content-Length header.