Skip to content

Streaming SSR

olegivanov edited this page May 9, 2026 · 3 revisions

Streaming SSR

React 19 added native streaming server-side rendering: renderToReadableStream from react-dom/server emits an HTML shell first, then streams <Suspense> boundary content as their promises resolve. The client picks up streamed chunks via inline <script> tags that swap fallback HTML for resolved content — no waiting for the slowest data fetch before the first byte is sent.

Real-Router ships a formal defer() API in @real-router/ssr-data-plugin that lets a single loader return both critical (await-before-shell) and deferred (stream-as-they-resolve) data, plus an inline-script settle wire format (<script>__rrDefer__("key","json")</script>) emitted by injectDeferredScripts from @real-router/ssr-data-plugin/server. Adapter consumers (<Await> / <Streamed> / useDeferred) live at the /ssr subpath of every adapter, e.g. @real-router/react/ssr. The router itself stays framework-agnostic — defer() is opt-in at the loader site, the wire format composes with React 19 use(promise) / Solid <Suspense> / Svelte {#await} / Vue <Suspense> + async setup() / Angular signal() without any framework-specific runtime.

This page documents the React variant end-to-end and links to the per-framework counterparts. For the alternative — RSC streaming via Flight protocol — see RSC Integration.


Mental Model

A typical streaming SSR page has two kinds of data:

Kind Where it lives When it resolves Example
Critical state.context.data (via ssr-data-plugin loader) Before shell renders — await router.start(url) Product name, price, description
Deferred Component-internal useMemo(() => fetch(...)) After shell — Suspense streams when promise settles Reviews, related items, comments

Critical data blocks shell delivery. Deferred data doesn't. The user sees critical content immediately and watches deferred sections fill in.

t=0ms       request received, loader runs
t=10ms      critical data resolved, renderToReadableStream returns
t=10ms      shell streamed: <article><h1>Product</h1>... + Suspense fallbacks
t=600ms     reviews promise resolves → React emits chunk, browser swaps fallback
t=1200ms    related promise resolves → React emits chunk, browser swaps fallback
t=1200ms    stream closes

The first byte (TTFB) is bounded by critical data resolution time, not by the slowest deferred promise.


Minimal Setup

Three files, no router-specific streaming wrappers.

Loader — defer({ critical, deferred }) (src/router/loaders.ts)

import { defer } from "@real-router/ssr-data-plugin";
import { LoaderNotFound } from "@real-router/ssr-data-plugin/errors";

import { getProduct, fetchReviews, fetchRelated } from "../database";

import type { DataLoaderFactoryMap } from "@real-router/ssr-data-plugin";

export const REVIEWS_KEY = "reviews" as const;
export const RELATED_KEY = "related" as const;

export const loaders: DataLoaderFactoryMap = {
  "products.detail": () => (params) => {
    const id = params.id as string;
    const product = getProduct(id);

    if (!product) throw new LoaderNotFound(`product:${id}`);

    return defer({
      critical: { product },
      deferred: {
        [REVIEWS_KEY]: fetchReviews(id),
        [RELATED_KEY]: fetchRelated(id),
      },
    });
  },
};

critical resolves before the shell renders (lands in state.context.data). Each deferred promise streams through inline <script>__rrDefer__("key","json")</script> settle scripts as it resolves, in resolution order — a slow promise doesn't hold a fast one. Reserved keys (__proto__/constructor/prototype) and non-promise values are rejected at validation time.

Suspense consumer with <Await> + useDeferred (src/components/Reviews.tsx)

import { Await } from "@real-router/react/ssr";

import { REVIEWS_KEY } from "../router/loaders";

import type { Review } from "../database";

export function Reviews() {
  return (
    <Await<Review[]> name={REVIEWS_KEY}>
      {(reviews) => <ReviewList items={reviews} />}
    </Await>
  );
}

<Await name> is a thin render-prop wrapper around use(useDeferred(name)). Surrounding <Suspense> (or the cross-adapter alias <Streamed fallback={…}>) shows fallback while pending; rejection bubbles to the nearest Error Boundary. Equivalent inline form:

import { use } from "react";
import { useDeferred } from "@real-router/react/ssr";

function Reviews() {
  const reviews = use(useDeferred<Review[]>("reviews"));
  return <ReviewList items={reviews} />;
}

Both forms read state.context.ssrDataDeferred[key]. Server: live promise from the loader (the actual Promise the loader returned). Post-hydration: registry-backed promise that resolves when the matching inline settle script lands (the bootstrap script installed in <head> populates the registry before the client bundle runs, so the promise is typically already resolved by the time use() reads it). Same call-site, two transparently different sources.

A typical production loader would put server-only delay in fetchReviews(...) itself (database query, downstream API). On the client, the registry-backed promise resolves synchronously once the settle script has landed — no hydration flash.

Server entry (src/entry-server.tsx)

import { UNKNOWN_ROUTE } from "@real-router/core";
import { cloneRouter } from "@real-router/core/api";
import { serializeRouterState } from "@real-router/core/utils";
import { RouterProvider } from "@real-router/react";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
import {
  getDeferBootstrapScript,
  injectDeferredScripts,
} from "@real-router/ssr-data-plugin/server";
import { renderToReadableStream } from "react-dom/server";

const baseRouter = createAppRouter();

export async function render(url: string) {
  const router = cloneRouter(baseRouter);
  router.usePlugin(ssrDataPluginFactory(loaders));

  const state = await router.start(url);
  const reactStream = await renderToReadableStream(
    <RouterProvider router={router}>
      <App />
    </RouterProvider>,
  );

  const deferred =
    (state.context as { ssrDataDeferred?: Record<string, Promise<unknown>> })
      .ssrDataDeferred ?? {};

  // Wrap the React stream — settle scripts ride the same chunked transfer,
  // interleaved with React's body chunks in promise-resolution order.
  const stream = injectDeferredScripts(reactStream, deferred, {
    bootstrap: false, // emit bootstrap separately into <head> for clean React hydration
  });

  // Strip live promises from the JSON state — only the keys list ships in
  // __SSR_STATE__; the client plugin reconstructs registry-backed promises
  // from the keys (and the inline settle scripts resolve them).
  const ssrJson = serializeRouterState(state, {
    excludeContext: ["ssrDataDeferred"],
  });

  return {
    stream,
    ssrJson,
    statusCode: state.name === UNKNOWN_ROUTE ? 404 : 200,
    deferBootstrap: Object.keys(deferred).length > 0
      ? `<script>${getDeferBootstrapScript()}</script>`
      : "",
    cleanup: () => router.dispose(),
  };
}

await renderToReadableStream(...) returns when the shell is ready (post-use(promise) first-render but pre-Suspense-resolution). injectDeferredScripts(stream, deferred) returns a new ReadableStream<Uint8Array> that forwards every byte of the underlying React stream and interleaves <script>__rrDefer__("key", "json")</script> tags as each deferred promise settles. The combined stream stays open until both the React stream is exhausted AND every deferred promise has settled.

Express integration (server/index.ts)

app.get("/{*path}", async (req, res) => {
  const { stream, ssrJson, statusCode, deferBootstrap, cleanup } =
    await module.render(req.originalUrl);

  const ssrScript = `<script>window.__SSR_STATE__=${ssrJson}</script>`;
  const templateWithState = template
    .replace("<!--defer-bootstrap-->", deferBootstrap)   // installs __rrDeferRegistry__ in <head>
    .replace("<!--ssr-state-->", ssrScript);
  const [headPart, footerPart] = templateWithState.split("<!--ssr-outlet-->");

  res.status(statusCode);
  res.set("Content-Type", "text/html; charset=utf-8");
  res.set("Transfer-Encoding", "chunked");
  res.write(headPart);

  const reader = stream.getReader();
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    res.write(Buffer.from(value));
  }

  res.write(footerPart);
  res.end();
  cleanup();
});

The server splits the HTML template at <!--ssr-outlet-->, writes the head immediately (including the defer-registry bootstrap), pipes React's streamed chunks plus the inline settle scripts into the body, then writes the footer. The bootstrap defines __rrDefer__/__rrDeferError__ global functions on globalThis before any settle script runs, so each settle resolves its registry entry deterministically. Browsers begin parsing as bytes arrive.

Note index.html needs a placeholder in <head>:

<head>
  <title>...</title>
  <!--defer-bootstrap-->
</head>

Client entry (src/entry-client.tsx)

import { hydrateRouter } from "@real-router/core/utils";
import { RouterProvider } from "@real-router/react";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
import { hydrateRoot } from "react-dom/client";

const router = createAppRouter();
router.usePlugin(ssrDataPluginFactory(loaders));

const ssrState = window.__SSR_STATE__;
await hydrateRouter(router, ssrState);

hydrateRoot(
  document.querySelector("#root")!,
  <RouterProvider router={router}>
    <App />
  </RouterProvider>,
);

ssr-data-plugin runs on the client too — hydrateRouter(router, ssrState) calls router.start(state.path) which triggers the loader, repopulating state.context.data with the same critical data the server resolved. Hydration sees identical state.


Comparison with Other Routers

React Router v7 / Remix — defer() + <Await>

// loader
export async function loader() {
  return defer({
    critical: await fetchCritical(),
    deferred: fetchDeferred(),
  });
}

// component
const data = useLoaderData<typeof loader>();
return (
  <>
    <Critical data={data.critical} />
    <Suspense fallback={<Spinner />}>
      <Await resolve={data.deferred}>
        {(value) => <Deferred value={value} />}
      </Await>
    </Suspense>
  </>
);

defer() is a marker that tells RR7 to wait on critical fields and stream deferred ones. <Await> is a render-prop wrapper around use(promise). Wire format is RR7-internal (turbo-stream-based).

Real-Router — defer() + <Await> (mirror API, framework-agnostic wire format)

// loader — critical AND deferred in one return
import { defer } from "@real-router/ssr-data-plugin";

export const loaders: DataLoaderFactoryMap = {
  "products.detail": () => (params) =>
    defer({
      critical: { product: getProduct(params.id as string) },
      deferred: {
        reviews: fetchReviews(params.id as string),
        related: fetchRelated(params.id as string),
      },
    }),
};

// component
import { Await, Streamed } from "@real-router/react/ssr";

function ProductDetail() {
  const { route } = useRoute<{ id: string }>();
  const { product } = route.context.data as { product: Product };

  return (
    <article>
      <Critical product={product} />
      <Streamed fallback={<Spinner />}>
        <Await<Review[]> name="reviews">
          {(reviews) => <ReviewList items={reviews} />}
        </Await>
      </Streamed>
    </article>
  );
}

Same defer({ critical, deferred }) shape, same <Await name> consumer. Differences:

  • Wire format is open and framework-agnostic<script>__rrDefer__("key","json")</script> settle scripts (industry-standard inline-settle approach used by Remix/RR7/TanStack Start). Browser executes them at HTML parse time, no custom client parser needed. Same wire format consumed by Preact (via thenable-throw <Await>), Vue (via async setup), Svelte (via {#await}), Solid (via createResource), Angular (via injectDeferred() signal). One server-side injectDeferredScripts call works across all 6 adapters.
  • <Streamed> is a cross-adapter alias for <Suspense fallback={…}> — pick <Streamed> for naming consistency with @real-router/{preact,solid,vue,svelte}/ssr, or use plain <Suspense> if you prefer one fewer abstraction.
  • useDeferred(key) is exposed for direct use() calls<Await> is a render-prop ergonomic over use(useDeferred(name)); both forms produce identical DOM.

For the inline form use(useDeferred(name)), see the consumer snippet under "Suspense consumer with <Await> + useDeferred" above.


Vue Counterpart

A Vue 3 port of this example lives at examples/web/vue/ssr-examples/ssr-streaming/ — same cloneRouter() per request, same ssrDataPluginFactory(loaders) for critical data, same serializeRouterState + hydrateRouter round-trip. The router-side code is identical; what changes is the framework's streaming primitive.

React 19 (this page) Vue 3 (counterpart)
Streaming primitive renderToReadableStream vue/server-renderer.renderToWebStream
Deferred-data API useMemo(() => fetchX()) + use(promise) async setup() with top-level await fetchX()
<Suspense> semantics in SSR Non-blocking — emits fallback marker (<!--$?-->), real content follows in a later chunk; client swaps via inline <script> Blocking — render of content after the boundary waits for every async setup() inside it to resolve before more HTML is emitted
Selective hydration Yes — hydrates resolved islands as chunks arrive No — app.mount("#root") hydrates the whole tree atomically
Error boundary componentDidCatch (class component) onErrorCaptured (returning false stops propagation)

What this means in practice: the Vue example streams chunks of HTML as the render tree resolves (better TTFB than buffered renderToString), and <Suspense> provides the canonical Vue pattern for awaitable deferred data inside a route. But you don't get the "fallback now, real content later, hydrate it independently" model that React 19 ships. True out-of-order streaming + lazy hydration in Vue is on the Vapor mode roadmap; the current example is honest about that gap rather than papering over it.

For everything that doesn't depend on streaming-primitive semantics — per-request isolation, plugin-driven critical data, hydration round-trip — the React and Vue examples are interchangeable. The Vue port required zero changes to @real-router/vue or @real-router/ssr-data-plugin.


Solid Counterpart

A Solid port of this example lives at examples/web/solid/ssr-examples/ssr-streaming/. Same cloneRouter() per request, same ssrDataPluginFactory(loaders) for critical data, same serializeRouterState + hydrateRouter round-trip. The streaming model is the closest non-React analogue: true out-of-order Suspense placeholders + selective hydration, with a different runtime mechanic.

React 19 (this page) Solid (counterpart)
Streaming primitive renderToReadableStream (Web Streams) solid-js/web.renderToStream (pipe() Node-shape sink + a tiny end() shim, bridged into a TransformStream<Uint8Array> via TextEncoder)
Deferred-data API useMemo(() => fetchX()) + use(promise) createResource(() => key, fetchX) accessor — Suspense pauses on first read
<Suspense> semantics in SSR Non-blocking — emits fallback marker (<!--$?-->); real content follows in a later chunk and React swaps via inline <script> Non-blocking — emits the fallback inline, then ships <template id="..."> chunks with the resolved subtree plus inline $df("…") splice scripts that run as soon as the chunk arrives
Selective hydration Yes — hydrates resolved islands as chunks arrive Yes — _$HY runtime (bootstrap injected by generateHydrationScript()) attaches per-island patches as the stream progresses
Error boundary componentDidCatch (class component) <ErrorBoundary fallback={(err, reset) => …}> (first-class component, no class boilerplate)
Hydration script Inlined automatically by renderToReadableStream Mandatory explicit generateHydrationScript() injection into <head> ahead of body

What this means in practice: the Solid example produces a streamed HTML body where every <Suspense> ships its fallback first, then the real subtree arrives in a later HTTP chunk wrapped in a <template id="…"> element. A tiny inline $df("…") script splices the template into the placeholder, and _$HY keeps track of which islands have hydrated. The rest of the page is interactive while deferred sections finish — same UX as React 19, different runtime mechanism.

Two implementation details are unique to Solid: generateHydrationScript() is mandatory and explicit (React inlines it via the renderer; Solid asks you to place it), and the writable handed to pipe() must implement both write(chunk) and end() — the published TS surface narrows to { write } but the runtime calls writable.end() once onCompleteAll fires. Both are documented in the example README; neither requires changes to @real-router/solid or @real-router/ssr-data-plugin.

For everything that doesn't depend on streaming-primitive semantics — per-request isolation, plugin-driven critical data, hydration round-trip — the React and Solid examples are interchangeable.


Svelte Counterpart

A Svelte 5 port of this example lives at examples/web/svelte/ssr-examples/ssr-streaming/. Same cloneRouter() per request, same ssrDataPluginFactory(loaders) for critical data, same serializeRouterState + hydrateRouter round-trip. The streaming model is fundamentally different from React 19/Solid — Svelte 5 stable does not stream chunked HTTP.

React 19 (this page) Svelte 5 (counterpart)
Streaming primitive renderToReadableStream (Web Streams) svelte/server.render — single buffered HTML response
Deferred-data API useMemo(() => fetchX()) + use(promise) {#await fetchX()} block (template-level) or <svelte:boundary> + pending snippet
<Suspense> semantics in SSR Non-blocking — emits fallback marker, real content follows in a later chunk; client swaps via inline <script> Pending branch only — server renders the {#await} template's pending case and returns immediately; async resolution does NOT happen on the server
Selective hydration Yes — hydrates resolved islands as chunks arrive No — hydrate() claims the full tree atomically; deferred sections resolve after that on the client
Network model Streaming (chunked HTTP) RSC-like — server shell, client data
Error boundary componentDidCatch (class component) {:catch error} branch on {#await} blocks, plus <svelte:boundary> + {#snippet failed(error, reset)} for component-level failures
Hydration script Inlined automatically by renderToReadableStream None needed — Svelte 5 hydration markers are inline in the body output

What this means in practice: the streamed HTML body does not contain data-review-id="r1" — Svelte ships the reviews-fallback placeholder, the fetchReviews() Promise resumes during client hydration (returning Promise.resolve() immediately on the client), and the browser DOM updates to the resolved reviews-section. The HTTP-level proof in the example's e2e suite checks for the fallback in the response, then asserts that the resolved sections become visible after page.goto(...).

This is the architectural opposite of React 19 streaming: React optimizes for "fallback now over the wire, real content streamed in chunks later"; Svelte 5 optimizes for "single fast response with pending UI, client takes over for real data". Both models hide the same network round-trips from the user; they spend the budget differently.

The reasoning behind documenting Svelte's pending-first model honestly (rather than papering over it with router-level workarounds) is the same as for Vue's blocking Suspense: framework choice is a load-bearing constraint, and the right router posture is to delegate to native primitives — {#await}, <svelte:boundary>, hydratable() (when needed) — instead of inventing wrappers. Standalone Svelte SSR with deferred-data semantics through native primitives, without SvelteKit framework lock-in.

For everything that doesn't depend on streaming-primitive semantics — per-request isolation, plugin-driven critical data, hydration round-trip — the React and Svelte examples are interchangeable. The Svelte port required zero changes to @real-router/svelte or @real-router/ssr-data-plugin.


Angular Counterpart

An Angular 21 port of this example lives at examples/web/angular/ssr-examples/ssr-streaming/. Same provideRealRouterFactory({ baseRouter, plugins }) per request, same ssrDataPluginFactory(loaders) for critical data, same cloneRouter per-request isolation. The streaming model is fundamentally different from React 19/Vue/Solid/Svelte — Angular uses per-@defer block lazy hydration rather than chunked streaming or pending snippets.

React 19 (this page) Angular 21 (counterpart)
Streaming primitive renderToReadableStream (Web Streams) AngularNodeAppEngine.handle(req)Response.body (ReadableStream) + writeResponseToNodeResponse bridge to Node res
Deferred-data API useMemo(() => fetchX()) + use(promise) @defer (on viewport|hover|interaction|idle|timer) template blocks with @placeholder / @loading / @error
<Suspense> semantics in SSR Non-blocking — emits fallback marker, real content follows in a later chunk @placeholder content shipped server-side; the real component is downloaded as a separate JS chunk and hydrated when the trigger fires (viewport, hover, interaction, idle, timer)
Selective hydration Yes — hydrates resolved islands as chunks arrive YesprovideClientHydration(withIncrementalHydration()) + hydrate on <trigger> syntax. Each @defer block hydrates independently on its trigger; the rest of the tree hydrates eagerly
Network model Streaming (chunked HTTP) Streaming HTTP + lazy JS chunks per @defer block
Error boundary componentDidCatch (class component) @error { ... } branch in @defer blocks
Hydration script Inlined automatically by renderToReadableStream Inlined automatically by @angular/ssr

What this means in practice: the streamed HTML body for /products/1 contains the critical product info (name, price, description) plus the @placeholder blocks for Reviews and RelatedItems. The browser receives the response, hydrates the eager parts, and then:

  • The @defer (on viewport) block for Reviews fires its IntersectionObserver immediately if the placeholder is in viewport at hydration time → downloads the Reviews chunk → hydrates → reviews-section replaces reviews-fallback. If the placeholder is below the fold, the trigger fires when the user scrolls.
  • The @defer (on hover) block for RelatedItems waits for an actual mouseenter event on the placeholder → downloads the RelatedItems chunk → hydrates → related-section replaces related-fallback.

The HTTP-level proof in the example's e2e suite checks the raw response body for the placeholders (reviews-fallback, related-fallback) plus the absence of the resolved sections (reviews-section, related-section); browser-level tests then assert that the resolved sections appear after viewport / hover triggers fire.

This model is most similar to React 19's selective hydration in spirit, but the trigger granularity is explicit and per-block (declared in template syntax via @defer (on <trigger>)) rather than implicit per-Suspense-boundary. The trade-off: more upfront declaration, more predictable hydration timing, no out-of-order placeholder swap (the placeholder DOM is replaced atomically when its block hydrates, not progressively as chunks arrive).

The reasoning behind documenting Angular's per-defer-block model (rather than aliasing it with React/Vue Suspense semantics) is the same as for Vue/Svelte: framework choice is a load-bearing constraint, and the right router posture is to delegate to native primitives — @defer, withIncrementalHydration(), AngularNodeAppEngine — instead of inventing wrappers. Standalone Angular SSR with view-agnostic routing through native Angular primitives.

For everything that doesn't depend on streaming-primitive semantics — per-request isolation, plugin-driven critical data, cookie-based DI — the React and Angular examples are interchangeable. The Angular port required zero changes to @real-router/angular adapter beyond the new provideRealRouterFactory API (added in #582 to support per-request DI scope) and zero changes to @real-router/ssr-data-plugin.


When to Use Streaming

Good fit:

  • Slow non-critical sections — comments, related products, recommendations, analytics widgets. Critical content (the page's reason for existing) renders immediately; secondary sections fill in.
  • Time-to-interactive optimization — the user can start reading before all data arrives.
  • Cascading data — primary data resolves fast, secondary data depends on primary and is slower.

Bad fit:

  • Render-blocking critical data — just await it in the loader. No benefit to deferring data the user must see.
  • Below-the-fold content<Suspense> is render-time; streaming doesn't help if the user has to scroll. Combine with IntersectionObserver for true lazy rendering.
  • Static content — if data is sync-available, <Suspense> adds overhead with no benefit.

Reference Implementation

A complete working example:

examples/web/react/ssr-examples/ssr-streaming/

  • Express + Vite SSR setup
  • ssr-data-plugin for critical product data
  • Suspense + use(promise) for deferred reviews and related items
  • Server-only artificial delays (600 ms reviews, 1200 ms related items) to make streaming observable
  • 5 Playwright scenarios covering shell timing, streaming markers, deferred visibility, hydration, and full-reload navigation

Run pnpm test:e2e from the example directory to see the streaming behavior verified end-to-end.


See Also

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

SSR-feature subpath — @real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.

  • Lazy (Svelte only — dynamic component import)
  • Await — read deferred SSR data by key
  • Streamed — Suspense-style boundary
  • ClientOnly — server fallback → client children swap
  • ServerOnly — server children, removed after hydration
  • HttpStatusCode — render-time HTTP status declaration
  • HttpStatusProvider — provides sink to descendant <HttpStatusCode>
  • useDeferred — read deferred Promise by key

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally