Summary

React v18’s Server-Side Suspense lets you stream HTML from the server in chunks, pausing rendering at suspense boundaries until data is ready. By using renderToPipeableStream (or renderToReadableStream in newer environments), you can send an initial shell quickly and fill in dynamic parts as data resolves, reducing Time to First Byte (TTFB) and speeding up hydration without client-only loading states (reactjs.org, infinite.red).

1. Introduction

Before React v18, Suspense only worked on the client: server rendering blocked until all data resolved, leading to slower TTFB and large HTML payloads. v18 introduces streaming SSR with Suspense support, enabling progressive HTML delivery and granular loading UIs on the server without client-side JavaScript.

2. Under the Hood: How It Works

  • renderToPipeableStream: Replaces renderToString. Returns a stream with callbacks (onShellReady, onAllReady, onShellError, onError).
  • Suspense Boundaries on Server: Mark regions that can suspend. The server flushes static shell until a boundary, then buffers suspended content and streams it when ready.
  • Streaming Protocol: React sends placeholders (<!--$!--> markers) and chunks wrapped in <div data-rsc> elements, which React’s client runtime picks up during hydration (reactjs.org).

3. Setup & Basic Example

3.1 Installation

npm install react@18 react-dom@18 express

3.2 Express Server

import express from 'express';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

const app = express();
app.get('/', (req, res) => {
  let didError = false;
  const { pipe, abort } = renderToPipeableStream(
    <App />,
    {
      onShellReady() {
        res.statusCode = didError ? 500 : 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res);
      },
      onError(err) {
        didError = true;
        console.error(err);
      }
    }
  );
  setTimeout(abort, 5000); // timeout stream after 5s
});
app.listen(3000);

3.3 Defining Suspense Boundaries

// App.jsx
import React, { Suspense } from 'react';
import PostList from './PostList';
import Spinner from './Spinner';

export default function App() {
  return (
    <html>
      <body>
        <h1>My Blog</h1>
        <Suspense fallback={<Spinner />}>
          <PostList />
        </Suspense>
      </body>
    </html>
  );
}

PostList suspends until it fetches posts via a resource loader (e.g., Relay or a custom promise-based fetch) and only that section waits, allowing the shell and other content to render immediately.

4. Use Cases & Patterns

  • Data-Driven Dashboards: Stream charts or stats as data arrives without full-page reloads.
  • Image Placeholders: Show layout and a tiny blur placeholder, then fill high-res as pending fetch completes.
  • Personalized Content: Render generic shell while user-specific data loads (e.g., recommendations).

5. Pitfalls & Best Practices

  • Boundary Granularity: Too many small boundaries increase overhead; too few delay meaningful content. Aim for sections that can load independently (e.g., sidebars, widgets).
  • Error Handling: Use onShellError vs onError wisely to avoid streaming error pages.
  • Timeouts: Always abort long streams to avoid hanging connections and DDoS risk.

6. Full Walkthrough

Build a minimal blog:

  1. Fetch Data: Create a simple resource loader:

    // resource.js
    let postsPromise;
    export function fetchPosts() {
      if (!postsPromise) {
        postsPromise = fetch('https://api.example.com/posts').then(r => r.json());
      }
      throw postsPromise; // Suspense throws promise pattern
    }
    
  2. Component:

    import React from 'react';
    import { fetchPosts } from './resource';
    
    export default function PostList() {
      const posts = fetchPosts();
      return (
        <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
      );
    }
    
  3. Streaming: As above, wrap <PostList /> in <Suspense> and observe streaming: initial HTML with <Spinner/>, then list markup.

7. Conclusion & Further Reading

Server-Side Suspense in React v18 brings streaming, granular loading, and progressive hydration to your SSR apps, cutting down TTFB and improving UX. Explore the React docs, RFC discussions, and community blogs for deeper dives.

Further Reading