Streaming HTML Fragments

Most slow pages are not slow. One section of them is — a chart, a report, a search across a big table — and the other twenty parts were ready in milliseconds and waited with it. The visitor does not see “almost everything”; the visitor sees nothing.

Streaming lets the server send the page now and that one section after, over the same response. reactolith swaps the late part into the live tree without rebuilding the rest of it. One request, no API, no JSON contract, no second controller run.

Turning it on

Streaming is off by default. With the option off nothing observes the document and the Router reads responses exactly as it always did — existing apps are untouched.

import { App } from "reactolith"; const app = new App( component, undefined, // appProvider undefined, // selector undefined, // root undefined, // document undefined, // fetch { streaming: true }, );

Placeholders

Any element carrying data-fragment="name" is a placeholder. Render whatever should be on screen while the real content is still being computed — a skeleton, a spinner, last week's cached number:

<ui-skeleton data-fragment="chart" class="h-[420px]"></ui-skeleton>

A <template data-fragment="chart">…</template> that arrives later replaces every placeholder with that name. Replacement, not filling: a fragment may be several top-level nodes or none at all, and an empty fragment makes the placeholder disappear — which is exactly what an empty section should do.

data-fragment is a document-wide address, not a React key: two placeholders may carry the same name on purpose, and both get filled. The attribute is stripped before props are built, so it reaches neither your component nor the DOM.

The siblings survive. The swap happens inside the component that renders the placeholder, so the components around it keep their identity and their state — even when one node turns into five.

The wire format

<!doctype html> <html> <body> <!-- the shell, flushed immediately --> <div id="reactolith-app"> <app-dashboard> <ui-stats json-values="…"></ui-stats> <ui-skeleton data-fragment="chart" class="h-[420px]"></ui-skeleton> </app-dashboard> </div> <script type="module" async src="/build/app.js"></script> <!--rl-shell-end--> <!-- … seconds later, over the same response … --> <template data-fragment="chart"><ui-chart json-points="…"></ui-chart></template> <rl-fragment data-fragment="chart"></rl-fragment> </body></html>

Three constants, and each one earns its place:

ConstantValueWhy it exists
SHELL_END <!--rl-shell-end--> Where the Router cuts. Without it a client cannot tell a complete page from a page that was cut off mid-tag.
FRAGMENT_ATTRIBUTE data-fragment Names a placeholder, and names the <template> that replaces it.
FRAGMENT_READY_TAG rl-fragment The completion marker. A <template> that appears in the DOM is not finished — its content is still arriving. The empty element behind it proves everything in front of it is whole.

The <template> is the container because its content is inert — no images load, no scripts run, nothing is visible — and because it sits outside the app root, so the browser's parser never writes into the subtree React owns.

Import the strings instead of retyping them (handy for a PHP/Ruby constant generated from the same source of truth):

import { SHELL_END, // "<!--rl-shell-end-->" FRAGMENT_ATTRIBUTE, // "data-fragment" FRAGMENT_READY_TAG, // "rl-fragment" FRAGMENT_READY_END, // "</rl-fragment>" } from "reactolith";
No response header, no token. A response without the sentinel is read to the end and rendered as one page, exactly as before — pages that do not stream need no changes at all. And nothing has to be echoed back by the backend: the stream that delivered the shell is the stream that delivers its fragments, and reactolith binds them to the render they belong to.

Where fragments may go

Recommended: inside <body>, with the closing tags written last. Everything stays valid HTML and every intermediary treats the response as an ordinary document.

Fragments after </html> also work — the HTML parser puts trailing content back into the body — but a validator will complain, and an intermediary that decides a document is finished at </html> may truncate the rest. Use it only if your framework cannot hold the closing tags back.

Do not put fragments inside the app root. That subtree belongs to React; the parser writing into it while React reconciles is the one thing this design exists to avoid.

The script tag

On a streamed page the entry script goes after the app root, with async:

<div id="reactolith-app">…</div> <script type="module" async src="/build/app.js"></script> <!--rl-shell-end-->

A plain <script type="module"> in <head> is deferred until the document has been parsed — and on a streamed page that is exactly the slow part. The app would boot only after the last fragment arrived, and the placeholder nobody ever saw would have been pointless. With async the app boots while the response is still open, paints the shell, and collects the fragments as the parser appends them.

This applies to the first page load only. Navigations go through the Router, where the app is already running.

Production shape. In dev, bundler preambles (Vite's React Fast Refresh, for one) must run before your entry module, and async gives up that ordering. Emit the async form for production builds and keep the ordinary module script in dev.

A Symfony backend

Any framework that can flush a response works. The shape is always the same: render the shell, flush, do the slow work, render the fragment, flush again.

#[Route('/dashboard')] public function dashboard(ChartRepository $charts): StreamedResponse { return new StreamedResponse(function () use ($charts) { echo $this->renderView('dashboard/shell.html.twig'); echo "<!--rl-shell-end-->"; flush(); // The expensive part — the visitor is already looking at the page. $points = $charts->lastQuarter(); echo $this->renderView('dashboard/_chart.html.twig', ['points' => $points]); echo '</body></html>'; flush(); }); } <template data-fragment="chart"> <ui-chart json-points="{{ points|json_encode|e('html_attr') }}"></ui-chart> </template> <rl-fragment data-fragment="chart"></rl-fragment>

Make sure nothing between your app and the browser buffers the response: disable output compression and buffering for the route (ob_end_flush(), X-Accel-Buffering: no behind nginx).

Mercure pushes

A Mercure message that is nothing but fragment templates is applied as fragments instead of being rendered as a page. This is the finest-grained update the library can do: one badge is reconciled and the rest of the tree is not even walked.

$hub->publish(new Update('/dashboard', '<template data-fragment="unread"><ui-badge>7</ui-badge></template>' ));

“Nothing but” is precise: whitespace and comments are fine, any other node means it is a page. A page that merely contains a template still renders as a page. Over Mercure there is no <rl-fragment> marker — an SSE message arrives whole. Listen with:

mercure.on("fragments:applied", (_event, names) => { console.log("updated in place:", names); });

API

const app = new App(component, undefined, undefined, undefined, undefined, undefined, { streaming: true, }); app.replace("chart", "<ui-chart>…</ui-chart>"); // string | Node | Node[] | null → boolean app.applyFragments(html); // → string[] of applied names app.isFragmentPayload(html);// → boolean app.pendingFragments(); // → string[] still waiting app.on("fragment:received", (name, content) => { /* DocumentFragment */ }); app.on("stream:ended", (pending) => { if (pending.length) console.warn("never arrived:", pending); });
MemberDescription
streamingtrue when the app accepts out-of-band fragments.
replace(name, content)Replace every placeholder carrying name. Returns false and warns when no placeholder wants it — the content is remembered anyway, so a fragment that arrives before its placeholder is not lost.
applyFragments(html)Apply every <template data-fragment> in a payload; returns the names that landed.
isFragmentPayload(html)Whether a payload is fragments only.
pendingFragments()Placeholders in the current tree that have no content yet.
fragment:receivedFires for every fragment taken in, with the name and its DocumentFragment.
stream:endedFires when the stream of the current render is over, with the names that never came.

Partial responses

Every navigation returns a whole page by default, which reactolith morphs in place. That is cheap on the client and often expensive on the server: after a form submit that toggles one flag, the backend re-renders a whole dashboard so the client can discover that one badge changed. The Router gives the backend the context to decide — and a way to answer smaller.

What the Router sends

HeaderValueWhen
X-Reactolith 1 (protocol version) Every visit. Its presence means “router navigation, not an address-bar load”.
X-Reactolith-From /dashboard?tab=sales Every visit — pathname and search of the page the request starts on.
Accept text/vnd.reactolith.fragments+html, text/html;q=0.9, */*;q=0.8 Only with streaming: true. An app that cannot apply fragments must never invite them.
X-Reactolith-Fragments unread,chart Only with sendFragmentNames: true — the placeholder names currently in the tree. Opt-in, because headers are finite.

They ride along on link clicks, form submits, router.navigate() and back/forward alike. All of them are same-origin, so no CORS preflight is added, and a backend that ignores them sees no change at all. A header you pass yourself is never overwritten — the caller wins.

What the backend may answer

Content-Type: text/vnd.reactolith.fragments+html; charset=utf-8 <template data-fragment="unread"><ui-badge>7</ui-badge></template>

The content type is the contract. As a courtesy a response whose body is nothing but fragment templates is treated the same way — the content type wins where both are present. Either way this only ever happens in an app with streaming: true.

A streamed page (shell + sentinel + tail) stays a page: the two paths do not overlap.

What a backend can rely on

  • No page render. Nothing outside the named placeholders is touched — component state and DOM nodes are exactly the ones from before the request.
  • History. An entry is pushed only when the final URL differs from the one in the address bar. A submit answered with fragments for the page you are already on adds nothing. replace keeps working as before.
  • Scroll. Never a jump to top, whatever the link or the caller asked for.
  • Events. fragments:applied fires with the applied names, and nav:ended still fires — a form that never learns it is done stays disabled forever. render:success does not fire: nothing was rendered.
  • The retry. If none of the fragments matched a placeholder, reactolith warns and fetches the same URL once more as a full page (Accept: text/html). The guard is internal, so a server cannot make it loop.
  • Redirects. response.redirected / response.url still decide the final URL; a 302 to a page that answers with a whole page keeps working.
Caching. A partial response for a URL that also serves a whole page must send Vary: Accept, or a shared cache will hand the fragments to a browser that asked for the page.

A Symfony controller

if ($request->getPreferredFormat() === 'reactolith-fragments' && $request->headers->get('X-Reactolith-From') === $this->generateUrl('dashboard')) { return new Response( $this->renderView('_unread.html.twig', ['count' => $count]), 200, ['Content-Type' => 'text/vnd.reactolith.fragments+html'], ); } return $this->render('dashboard.html.twig', …); // the whole page, as before

Register the format once (in a request listener or Request::setFormat()) so getPreferredFormat() knows it:

Request::setFormat('reactolith-fragments', ['text/vnd.reactolith.fragments+html']);

Turning the names on is one option, and worth it when one URL serves several trees:

new App(component, undefined, undefined, undefined, undefined, undefined, { streaming: true, sendFragmentNames: true, // → X-Reactolith-Fragments: unread,chart }); app.fragmentNames(); // every placeholder in the tree app.pendingFragments(); // the ones still waiting app.router.on("fragments:applied", (_input, _init, _push, _res, _html, url, names) => { console.log("updated", names, "from", url); });

What happens when

  • First load: a MutationObserver collects what the parser appends, plus one sweep for whatever arrived before the app booted. DOMContentLoaded ends the stream.
  • Navigation: the Router cuts the response at the sentinel, renders the shell, and keeps reading the rest in the background.
  • Navigating away mid-stream: the abandoned response is cancelled and its late fragments are dropped — they can never land in the page that replaced it.
  • A new page: the fragment registry is cleared. The previous page's fragments address placeholders that no longer exist.
  • Server-side rendering: there is no “later” on the server, so renderToString renders a placeholder's skeleton.