Pages are streamed to the browser while they render. Everything above a slow component is sent right away, so the browser can already load your CSS and show the header while data is still loading. You don't have to do anything to turn this on: every JSX response is streamed.

How a page is streamed

Jeasx renders your JSX in order, from top to bottom, with jsxToStream from jsx-async-runtime:

  • Whenever rendering has to wait (for an async component, a promise or the next item of an async iterable), the markup rendered so far is sent to the browser as a chunk.
  • Large pages are sent in chunks of about 16 KB, even if nothing is async, so the complete page never has to be held in memory.
  • Rendering pauses while a slow client catches up, and stops when the client disconnects.

Put slow parts last

As async components run one after another, a slow component delays everything below it. So put slow parts as late in the page as possible, and start slow requests early to load them in parallel (see parallel loading):

export default function ({ request }) {
  // Start the slow request right away, so it runs while the page renders.
  const reviews = loadReviews(request.query.id);

  return (
    <Layout title="Product">
      {/* Sent to the browser immediately */}
      <ProductHeader id={request.query.id} />
      {/* Sent as soon as the reviews have arrived */}
      <Reviews reviews={reviews} />
    </Layout>
  );
}

Streaming lists

Iterables (e.g. Set or generators), async iterables and ReadableStream can be used as children. They are rendered in order as items arrive. Async generators let you stream long lists item by item, e.g. rows from a paginated API or a database cursor:

async function* Users() {
  for await (const user of db.users.find()) {
    yield <li>{user.name}</li>;
  }
}

export default function () {
  return (
    <ul>
      <Users />
    </ul>
  );
}

If the client disconnects, the generator is stopped like a for await loop on break, so its finally block runs and can close the cursor. A ReadableStream (e.g. a fetch() body) can be rendered as a child the same way. Its text is escaped like any other string.

Status codes and headers come first

The status code and headers are sent with the first chunk. Set them in your guards or route before you return JSX, and decide about redirects or a 404 there, too (see handling missing data). Calling reply.status() or reply.header() in a nested component has no effect once streaming has started.

Errors while streaming

Your Fastify error handler only runs for errors thrown before the first chunk is sent. Once streaming has started, an error thrown by an async component cuts the page off.

So load data that can fail and that decides about the status code in the route, before returning JSX (see Data & Async). For optional parts like a widget calling an unreliable API, wrap the component in an error boundary which renders it to a string first and shows a fallback if it fails:

// src/components/ErrorBoundary.jsx
import { html, jsxToString } from "jsx-async-runtime";

export default async function ErrorBoundary({ fallback, children }) {
  try {
    return html(await jsxToString.call(this, children));
  } catch (error) {
    this.request.log.error(error);
    return fallback;
  }
}
<ErrorBoundary fallback={<p>The weather is currently unavailable.</p>}>
  <Weather city="Hamburg" />
</ErrorBoundary>

The example uses this.request for logging, so it expects a guard which puts the request into the context, e.g. this.request = request in src/[...guard].js. As the boundary renders its children to a string first, they aren't streamed, so use it only where you need it.

What turns streaming off

Some features need the complete markup at once. They still work, but the affected part of the page is sent as a single chunk:

  • A this.jsxToString override renders everything below it to a string (see transforming JSX). In a root guard, it affects the whole page.
  • A this.responseHandler which reads the stream into a string, e.g. to format or minify the HTML (see FAQ).
  • An error boundary renders its children to a string, so it can show a fallback instead.

Streaming other content

Routes can also return a ReadableStream, a Node.js stream or a Response (e.g. from fetch()), which is sent as is. A Response brings its own status and headers. For streams, set a content type with reply.type(), otherwise they're sent as text/html. This is useful for proxying an API, large exports or Server-Sent Events.

Proxies and compression

Streaming only helps if nothing between the server and the browser buffers the response. Some reverse proxies wait for the complete response by default (e.g. nginx, which you can tell via proxy_buffering off or an X-Accel-Buffering: no header). Compression can also hold back small chunks until enough data has piled up.

Check that streaming works

Open the network tab of your browser and check that the first bytes of a slow page arrive early. If the whole page arrives at once, something between the server and the browser buffers the response.

See it in action