John Aleman

Optimizing Server Rendering Experiences

Streaming and granular Suspense boundaries, so the user sees the important parts of a page immediately and everything else fills in as it becomes ready.

View the live demo →
t: "streaming"

Don't wait for the slowest query to send anything

Without streaming, server rendering is only as fast as its slowest piece: the server holds the response until every data dependency on the page has resolved, then sends one HTML document. A three second recommendations query means a three second blank screen, even if the page's metrics were ready in 600ms.

Streaming sends the response in chunks instead of one shot. The server flashes the parts of the page that don't depend on slow data immediately, then streams each remaining piece in as its own data resolves — in whatever order that happens to be.

The user isn't waiting for a response. They're watching one arrive.

t: "suspense boundaries"

Suspense marks where a section is allowed to lag

React streams HTML in chunks aligned with <Suspense> boundaries. Wrap an async component in one and its fallback ships with the initial response; the real content swaps in once the component's own data resolves, without blocking anything outside that boundary.

<Suspense fallback={<LoadingSpinner text="Loading metrics..." />}>
  <OverviewMetrics />
</Suspense>

<Suspense fallback={<LoadingSpinner text="Loading transactions..." />}>
  <TransactionFeed />
</Suspense>

<Suspense fallback={<LoadingSpinner text="Analyzing your data..." />}>
  <RecommendationEngine />
</Suspense>

One boundary per page means one fallback for everything — the fastest section waits on the slowest just as much as before, it's just a spinner doing the waiting instead of a blank tab. Granular boundaries — one per independent section — are what actually let each piece resolve on its own schedule.

t: "prioritization"

Above-the-fold first, everything else after

Content outside any <Suspense> — layouts, navigation, a page header — renders immediately as the static shell, before a single byte of dynamic data has arrived. That's where the page's most important content belongs: a title or hero image inside a Suspense boundary can't paint until that boundary resolves, which is exactly the delay streaming was supposed to remove.

Boundary order in the markup only sets visual position, not arrival order — a boundary further down the page can still resolve first. Put secondary and slower sections in boundaries of their own, sized so their fallback doesn't reshuffle the layout when the real content swaps in.

t: "error isolation"

One failing section shouldn't sink the page

Next.js's error.tsx file convention catches a failure for an entire route segment — useful, but coarse when three independent sections share one page and only one of them is down. error.tsx is itself built on a React error boundary, so the same mechanism can be scoped by hand: wrap each <Suspense> in its own error boundary, and a rejected section renders its own fallback while its siblings render normally.

The live demo below has a "simulate a failure" control for exactly this — pick a section and watch it fail in place while the other two keep working.

t: "monitoring"

Track which sections are slow

Independent boundaries mean independent timing — instead of one page-load number, each section can report how long its own data took. That's the signal that tells you what's actually worth caching or optimizing next, rather than guessing from an aggregate that blends a 600ms query with a 3 second one.

The demo below logs each section's resolve time to the console and renders it inline, standing in for what a real app would send to an APM tool per section instead.

t: "example"

The analytics demo's structure

Here's how the live demo puts the four ideas above together: a static header outside any boundary, three sections each with their own Suspense and error boundary, and simulated latency so the streaming order is visible.

app/patterns/optimizing-server-rendering/
├── data.ts
└── analytics/
    ├── layout.tsx   ← static shell: header, back link
    └── page.tsx     ← three Suspense + error boundary pairs

components/optimizing-server-rendering/
├── OverviewMetrics.tsx       (~600ms)
├── TransactionFeed.tsx       (~1800ms)
├── RecommendationEngine.tsx  (~3200ms)
└── SectionErrorBoundary.tsx

components/patterns/loading-spinner.tsx  ← shared Suspense fallback

There are three important pieces here.

The layout is the static shell. Nothing in layout.tsx awaits anything, so it paints before any of the three sections have started streaming.

Each section is its own boundary. Key Metrics, Recent Activity, and AI Recommendations each get a dedicated <Suspense>, so the 600ms section doesn't wait on the 3200ms one.

Each boundary has its own error boundary. SectionErrorBoundary wraps every <Suspense>, so the "simulate a failure" control can take down one section without touching the other two.

The live demo at the top of this page puts all three pieces into practice — reload it and watch each section arrive on its own timer instead of all three appearing at once.