Streaming SSR with Remote Component Boundaries #
With non-streaming server rendering, the slowest remote on the page decides when any of it reaches the browser — this page puts each remote behind its own Suspense boundary so the shell flushes immediately and remotes fill in as they resolve.
The fit is unusually good. Federation already gives you independent units with independent failure modes and independent latency; streaming gives each of those units its own flush point. What it does not give you for free is a sensible fallback, a bounded wait, or agreement with the client on what was flushed. It extends server-side rendering federated modules.
Prerequisites #
- React 18+ with
renderToPipeableStream(Node) orrenderToReadableStream(edge runtimes). - A working Node federation setup, per configuring Node federation for SSR remotes.
- A server or edge platform that does not buffer the whole response — a proxy with buffering enabled silently undoes all of this.
- Each remote able to declare a minimum height for its fallback, as in lazy loading remotes below the fold.
Step 1 — Decide what belongs in the shell #
The shell is everything that flushes in the first chunk: the document head, navigation, layout scaffolding and any content already in hand. It must not await anything.
Anything the shell awaits delays the first byte for every user, which is the exact problem streaming exists to solve. If a remote genuinely owns the page’s headline content, it belongs in the shell and should be awaited — but be deliberate, because that is a decision to make the whole page as slow as that remote.
// server/App.jsx — shell renders synchronously, remotes suspend
export function App({ route }) {
return (
<Layout>
<Nav /> {/* shell: no awaits */}
<MainContent route={route} /> {/* shell: already-resolved data */}
<RemoteSlot name="recommendations" height={320} />
<RemoteSlot name="reviews" height={480} />
</Layout>
);
}
Step 2 — Put each remote behind its own boundary #
One boundary per remote is the whole design. A shared boundary around several remotes means the slowest one gates all of them, reproducing the original problem inside a smaller box.
// server/RemoteSlot.jsx
import { Suspense, lazy } from 'react';
export function RemoteSlot({ name, height }) {
const Remote = lazy(() => loadServerRemote(name, './Widget'));
return (
<div data-remote={name} style={{ minHeight: height }}>
<RemoteErrorBoundary remote={name} fallback={null}>
<Suspense fallback={<SlotSkeleton height={height} />}>
<Remote />
</Suspense>
</RemoteErrorBoundary>
</div>
);
}
The error boundary sits outside the Suspense boundary. Inside, a rejected promise would be treated as a suspension rather than a failure, and the fallback would never resolve.
The reserved minHeight matters more when streaming than when lazy-loading on the client, because the fallback is genuinely visible to every user for a measurable period rather than only to those who scroll.
Step 3 — Stream the response #
renderToPipeableStream flushes the shell as soon as it is ready and continues emitting chunks as boundaries resolve.
// server/render.js
import { renderToPipeableStream } from 'react-dom/server';
export function handle(req, res) {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App route={req.path} />, {
bootstrapScripts: ['/client.js'],
bootstrapScriptContent: `window.__REMOTES__=${safeJson(resolvedRemotes)}`,
onShellReady() {
// The shell is complete: send headers and start streaming.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
pipe(res);
},
onShellError(err) {
// The shell itself failed — nothing useful has been sent yet.
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><p>Something went wrong.</p>');
reportError('shell-error', err);
},
onError(err) {
// A boundary failed. The shell is already streaming, so recover per region.
didError = true;
reportError('boundary-error', err);
},
});
// Hard stop: never hold a connection open for a remote that will not resolve.
setTimeout(abort, 5000);
}
The distinction between onShellError and onError is the one to internalise. A shell error means nothing has been sent and you can still return a clean 500. A boundary error happens after headers are out, so the only options are recovering that region on the client or leaving its fallback in place.
Step 4 — Bound every remote, not just the whole stream #
The 5-second abort is a backstop for the connection. It is far too coarse for an individual remote, which should give up in a few hundred milliseconds and let the client take over.
// server/load-with-deadline.js
export function loadWithDeadline(name, module, ms = 400) {
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new RemoteTimeout(name)), ms);
});
return Promise.race([loadServerRemote(name, module), deadline])
.finally(() => clearTimeout(timer));
}
Throwing a distinct RemoteTimeout rather than a generic error lets the boundary decide differently: a timeout should leave the fallback in place and let the client render it, while a genuine render error should collapse the region. Conflating them means either retrying things that will always fail, or giving up on things that would have worked.
Step 5 — Keep the client in agreement about what was flushed #
The client hydrates whatever arrived. If a boundary never resolved on the server, its fallback is what is in the DOM, and the client must know to render the real thing there rather than assuming markup exists.
// server: record per-remote outcomes as they resolve
const outcomes = {}; // { recommendations: 'rendered' | 'timeout' | 'error' }
// …serialised alongside the resolved versions
bootstrapScriptContent:
`window.__REMOTES__=${safeJson(resolvedRemotes)};` +
`window.__REMOTE_STATUS__=${safeJson(outcomes)};`
// client/RemoteSlot.jsx — hydrate rendered regions, mount the rest
export function RemoteSlot({ name, height }) {
const status = window.__REMOTE_STATUS__?.[name];
const Remote = lazy(() => loadBrowserRemote(name, './Widget'));
return (
<div data-remote={name} style={{ minHeight: height }}>
<RemoteErrorBoundary remote={name} fallback={null}>
<Suspense fallback={<SlotSkeleton height={height} />}>
{/* 'rendered' hydrates in place; anything else mounts fresh. */}
<Remote data-ssr={status === 'rendered'} />
</Suspense>
</RemoteErrorBoundary>
</div>
);
}
Without this, a region whose server render timed out produces a hydration mismatch on every affected page view — the client expects markup that was never sent.
Step 6 — Make sure nothing between you and the user buffers #
Streaming is undone silently by any intermediary that waits for a complete response. This is the failure that wastes the most time, because the code is correct and the behaviour is not.
# nginx in front of a streaming Node app
location / {
proxy_pass http://app;
proxy_buffering off; # do not accumulate the response
proxy_http_version 1.1;
proxy_set_header Connection "";
gzip off; # gzip buffering defeats early flush
chunked_transfer_encoding on;
}
Compression is the subtle one. Gzip at the proxy typically buffers to fill its window, which delays the shell by exactly as long as the slowest boundary — restoring the original behaviour while every metric in your application says streaming is working. If you need compression, use a streaming-aware configuration and verify the flush timing afterwards rather than assuming.
Check the whole path: a CDN, a load balancer, a service mesh sidecar and a security proxy can each buffer independently.
Step 7 — Verify the timing, not the output #
The output of a streamed response and a buffered one is identical. Only the timing differs, so that is what the test asserts.
// test/streaming.spec.mjs — assert the shell arrives well before the remotes
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('shell flushes before slow remotes resolve', async () => {
const started = Date.now();
const res = await fetch(`${BASE}/checkout`);
let shellAt = null;
for await (const chunk of res.body) {
const text = new TextDecoder().decode(chunk);
if (shellAt === null && text.includes('</nav>')) shellAt = Date.now() - started;
}
const total = Date.now() - started;
assert.ok(shellAt < 300, `shell took ${shellAt}ms`);
assert.ok(total - shellAt > 100, 'response arrived in one chunk — something is buffering');
});
The second assertion is the one that catches a buffering proxy. If the shell and the remotes arrive together, streaming is not happening regardless of what the server code does.
Verification #
- The shell arrives first:
curl -N --raw $URLand watch the output appear in stages rather than all at once. - Time to First Byte drops: compare against the non-streaming build. It should fall to roughly the shell’s own render time.
- A slow remote does not delay the shell: add an artificial 2-second delay to one remote and confirm the shell still flushes promptly.
- Boundaries recover independently: make one remote throw and confirm the rest of the page streams normally.
- No hydration mismatch on timeouts: force a remote to time out on the server and confirm the client mounts it cleanly with no recovery beacon.
- Nothing buffers: run the timing test from step 7 against the real production path, including the CDN.
Troubleshooting #
Symptom: the whole page still arrives at once.
Diagnosis: something in the path is buffering — most often gzip at a proxy or a CDN that does not support streaming origins. Fix: disable buffering hop by hop and re-run the timing test after each change.
Symptom: onShellReady never fires.
Diagnosis: a component in the shell is suspending, so the shell is never complete. Fix: move whatever awaits into its own boundary; the shell must render synchronously.
Symptom: fallbacks flash and are replaced immediately.
Diagnosis: boundaries resolve so fast that the fallback appears for one frame. Fix: this is usually fine, but if it is distracting, render the remote inline in the shell instead — its latency does not warrant a boundary.
Symptom: hydration mismatches appear only on slow requests.
Diagnosis: the client assumes markup exists for a region whose server render timed out. Fix: serialise per-remote outcomes as in step 5 and branch on them during hydration.
Symptom: the connection hangs for the full abort timeout.
Diagnosis: a remote’s promise never settles, so only the global abort ends it. Fix: bound each remote individually with its own deadline, as in step 4.