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.
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.
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:
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.
Three constants, and each one earns its place:
| Constant | Value | Why 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):
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.
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.
On a streamed page the entry script goes after the app
root, with async:
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, andasyncgives up that ordering. Emit theasyncform for production builds and keep the ordinary module script in dev.
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.
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).
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.
“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:
| Member | Description |
|---|---|
streaming | true 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:received | Fires for every fragment taken in, with the name and its DocumentFragment. |
stream:ended | Fires when the stream of the current render is over, with the names that never came. |
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.
| Header | Value | When |
|---|---|---|
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.
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.
replace keeps working as before.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.Accept: text/html). The guard is internal, so a server cannot make it loop.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.
Register the format once (in a request listener or
Request::setFormat()) so
getPreferredFormat() knows it:
Turning the names on is one option, and worth it when one URL serves several trees:
MutationObserver collects what the parser appends, plus one sweep for whatever arrived before the app booted. DOMContentLoaded ends the stream.renderToString renders a placeholder's skeleton.