Flavours of Server-Side Rendering in React

Server-Side Rendering (SSR) has evolved a lot since React first introduced it.
Today, we can choose from multiple flavours — from the classic renderToString to modern Suspense-driven streaming in React 18+.
Each one has its own trade-offs between control, complexity, and performance.
In this post, I’ll walk you through how each approach works, what kind of performance gain you can expect, and when to use which one — all backed by real code from my SSR-demo repository.
Let’s dive in 🚀
What We’ll Cover
- How the demo project is wired and how to run it locally
- Classic SSR with
renderToStringfor straightforward hydration - Manual streaming using chunked responses for progressive rendering
- Suspense streaming with
renderToPipeableStreamand selective hydration - Observability techniques, migration paths, and production tips
Why SSR Still Matters
Client-side rendering shines once the browser has booted our app, but it often stumbles on slower networks or CPU-constrained devices. Shipping meaningful HTML straight from the server still solves real problems:
Typical CSR pain points
- 3–8 s Time to Interactive on sub-4G connections
- Delayed content discovery for search crawlers
- Layout shifts while client code hydrates
What SSR fixes immediately
- HTML arrives ready to paint (better FCP/LCP)
- Crawlable content without headless browser workarounds
- Predictable rendering for marketing, checkout, and dashboard-critical paths
Rather than treating SSR as a binary choice, React 18 offers a spectrum. Understanding the mechanics behind each flavour lets us mix and match intentionally.
The Reference Project
All examples reference the SSR-demo repo. It ships an Express server with three routes, a shared product-page UI, and instrumentation to show how each technique behaves.
▶Getting the repo running
▶Key files
Flavour 1: Classic SSR with `renderToString`
Classic SSR renders the entire component tree on the server before sending a single byte. It is conceptually simple, universally supported, and provides deterministic HTML.
▶How it works
▶Server route
▶Component structure
▶Characteristics
▶When to choose it
Flavour 2: Manual Streaming with Chunked Responses
Manual streaming sends the initial HTML shell immediately and streams slower sections as they become available. We control chunk boundaries, timing, and fallbacks explicitly.
▶Why use it
▶Server route
▶Component slices
▶Characteristics
▶When to choose it
Flavour 3: Suspense-Driven Streaming with React 18
React 18’s renderToPipeableStream brings streaming into the core API. Suspense boundaries decide when to flush HTML to the client, and selective hydration keeps parts of the UI interactive as soon as their data resolves.
▶Key ideas
▶Server route
▶Suspense-aware component tree
▶Characteristics
▶When to choose it
Putting the Flavours Side by Side
| Dimension | Classic SSR | Manual Streaming | Suspense Streaming |
|---|---|---|---|
| First content paint | Waits for full HTML | Immediate shell | Immediate shell + Suspense fallbacks |
| Complexity | Low | Medium (manage timers/placeholders) | Medium-high (React 18 + boundaries) |
| Error isolation | Global try/catch | Per chunk if we implement it | Automatic per Suspense boundary |
| Peak memory | Highest (entire HTML in memory) | Lower (chunks rendered separately) | Lower (React manages buffers) |
| Compatibility | Works everywhere | Works everywhere | React 18+ |
| Ideal use case | Fast data, marketing pages | Predictable staged data | Complex UIs, mixed data sources |
Measuring and Debugging
▶Inspecting the network waterfall
▶Instrumentation hooks
Key Takeaways
- React’s SSR options form a continuum—pick the flavour that matches our latency profile and team workflow.
- Streaming (manual or Suspense) drastically improves perceived speed without rewriting our entire UI.
- Suspense provides the safest path for complex apps thanks to built-in fallbacks, selective hydration, and error isolation.
- Instrumentation is non-negotiable: measure TTFB, chunk arrival times, and hydration timing to spot regressions early.
Let’s Try it out
- Clone the SSR-demo repo.
- Change the artificial delays in
server.js(or the data helpers it imports) to simulate real-world APIs. - Observe the difference in Chrome DevTools → Network, Performance, and Coverage tabs.
Interactive sandbox
Once we have exercised all three approaches, we know which flavour fits the next project—and how to switch to another when requirements evolve.