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.

Buffered versus streamed federated rendering Streaming decouples each remote's latency from the document's, so the shell reaches the browser at its own speed rather than the slowest remote's. One buffered response Server awaits every remote Slowest remote sets Time to First Byte Nothing paints until all resolve One unhealthy remote stalls the page Streamed, boundary per remote Shell flushes as soon as it renders Each remote flushes when ready Slow remote shows its own fallback Failure is scoped to one region
Federation already gives you independent units — streaming is what stops the response from re-coupling them.

Prerequisites #

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 #

What the browser receives, and when Each boundary flushes independently as it resolves, and a remote that misses its deadline leaves its fallback in place rather than delaying the stream. A streamed response, boundary by boundary 0 ms shell rendered flushed immediately 120 ms reviews resolves chunk flushed 310 ms recommendations resolves chunk flushed 400 ms deadline hit fallback kept, client takes over Each remote has its own deadline; the connection has a separate, much longer backstop.
The last marker is the important one: a missed deadline is a normal outcome with a defined behaviour, not an error.

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 #

How an intermediary silently undoes streaming Any hop that accumulates the response before forwarding it restores the original behaviour while the application's own metrics still report success. The failure that looks like working code Node flushes shell first chunk Proxy buffers gzip fills its window CDN buffers waits for the full body Browser receives one chunk, no benefit
Verify the timing end to end through the real production path — every hop can buffer independently.

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 #

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.