Streaming Rendering
Master streaming rendering — React Suspense-based streaming SSR, chunked transfer encoding, out-of-order HTML delivery, shell-first rendering, progressive page assembly, and error handling for streamed content to achieve the fastest possible Time to First Byte and progressive content delivery.
When to Use
- SSR blocks on slow data fetches, delaying TTFB for the entire page
- Users wait for the full page to render before seeing any content
- A page depends on multiple data sources with different response times
- Traditional SSR returns a blank screen until all data is fetched and rendered
- TTFB is high because the server waits for the slowest API before responding
- Above-fold content is ready quickly but below-fold content blocks the response
- A page mixes fast data (cached) with slow data (database, third-party API)
- Progressive enhancement requires content to appear incrementally
- React 18+ is available and streaming SSR can replace renderToString
- Server response is buffered entirely before sending, wasting time-to-first-byte potential
Instructions
Understand streaming versus buffered SSR. Traditional SSR buffers the entire HTML response. Streaming sends HTML chunks as they become ready:
Buffered SSR (renderToString):
Server: [fetch all data...400ms] [render...100ms] [send complete HTML]
Browser: [receive...parse...FCP]
TTFB: 500ms | FCP: 600ms
Streaming SSR (renderToPipeableStream):
Server: [render shell...20ms] [stream shell HTML] [fetch slow data...] [stream rest]
Browser: [receive shell...FCP] [...progressive content]
TTFB: 20ms | FCP: 100ms | Full content: 500ms
Structure the page with Suspense boundaries for streaming. Each Suspense boundary is a potential streaming point:
// The server streams each Suspense boundary independently
export default function DashboardPage() {
return (
<Shell>
{/* Streams immediately — no data dependency */}
<Header />
<Navigation />
<div className="dashboard-grid">
{/* Streams when metrics data resolves (~50ms) */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
{/* Streams when chart data resolves (~200ms) */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Streams when activity data resolves (~400ms) */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</Shell>
);
}
Implement streaming in a Node.js server. Use renderToPipeableStream with onShellReady:
import { renderToPipeableStream } from 'react-dom/server';
async function handleRequest(req: Request, res: Response) {
const { pipe, abort } = renderToPipeableStream(
<App url={req.url} />,
{
bootstrapScripts: ['/client.js'],
onShellReady() {
// The app shell (everything outside Suspense) is ready
// Start streaming immediately — don't wait for Suspense content
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
pipe(res);
},
onAllReady() {
// Everything including Suspense content is ready
// For crawlers/bots, wait for this instead of onShellReady
},
onShellError(error) {
// Shell failed to render — send error page
res.statusCode = 500;
res.end('<h1>Something went wrong</h1>');
},
onError(error) {
// Non-shell error — log but continue streaming
console.error('Streaming error:', error);
},
}
);
// Abort streaming after timeout
setTimeout(() => abort(), 10000);
}
Handle bot/crawler requests differently. Detect bots via user-agent (/bot|crawl|spider/i). For users, call pipe(res) in onShellReady for fast TTFB. For bots, call pipe(res) in onAllReady so they receive complete HTML for SEO indexing.
Understand out-of-order streaming mechanics. React streams HTML in order (shell first), then replaces Suspense fallbacks out-of-order as data resolves:
<!-- Initial stream: shell + fallbacks -->
<html>
<body>
<header>...</header>
<div id="metrics">
<!--$?--><template id="B:0"></template>
<div class="skeleton">...</div>
<!--/$-->
</div>
<div id="chart">
<!--$?--><template id="B:1"></template>
<div class="skeleton">...</div>
<!--/$-->
</div>
<!-- Streamed later when metrics resolve (out of order, before chart): -->
<div hidden id="S:0"><div class="metrics-panel">Real metrics content...</div></div>
<script>
$RC('B:0', 'S:0');
</script>
<!-- $RC swaps the fallback template with the real content -->
<!-- Streamed even later when chart resolves: -->
<div hidden id="S:1"><div class="chart">Real chart content...</div></div>
<script>
$RC('B:1', 'S:1');
</script>
</body>
</html>
Implement error boundaries for streamed content. Individual Suspense sections can fail without crashing the entire stream:
// Error boundary catches failures in individual sections
function StreamSection({ children, fallback }) {
return (
<ErrorBoundary fallback={<SectionError />}>
<Suspense fallback={fallback}>
{children}
</Suspense>
</ErrorBoundary>
);
}
function DashboardPage() {
return (
<Shell>
<StreamSection fallback={<MetricsSkeleton />}>
<MetricsPanel /> {/* If this fails, shows SectionError */}
</StreamSection>
<StreamSection fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Independent — unaffected by metrics failure */}
</StreamSection>
</Shell>
);
}
Configure streaming in Next.js App Router. Next.js App Router streams by default with loading.tsx files:
// app/dashboard/loading.tsx — automatic Suspense boundary
export default function DashboardLoading() {
return <DashboardSkeleton />;
}
// app/dashboard/page.tsx — async component triggers streaming
export default async function Dashboard() {
const metrics = await getMetrics(); // data fetch during streaming
return <MetricsDisplay data={metrics} />;
}
// Nested streaming with parallel data fetching:
// app/dashboard/@metrics/page.tsx — streams independently
// app/dashboard/@chart/page.tsx — streams independently
// app/dashboard/layout.tsx — renders the slot composition
Details
Chunked Transfer Encoding
Streaming SSR uses HTTP chunked transfer encoding (or HTTP/2 DATA frames). The server sends the response in chunks without knowing the total content length upfront. The browser incrementally parses and renders each chunk. This is a standard HTTP/1.1 feature (Transfer-Encoding: chunked) and is native to HTTP/2 and HTTP/3. No special client-side code is needed — browsers have always supported incremental HTML parsing.
Shell Content Selection
The "shell" is everything outside Suspense boundaries. Choosing what goes in the shell is a critical design decision. Shell content should be: (1) immediately available (no data fetching), (2) visually meaningful (layout, navigation, headers), (3) sufficient for LCP (if LCP is text-based). Shell content should NOT include: data-dependent content, personalized content, or content that requires slow API calls.
Worked Example: Vercel Dashboard
Five Suspense boundaries: navigation (shell, instant), project list (30ms from cache), deployment status (100ms from API), analytics (300ms), team activity (500ms). Users see navigation and project list within 50ms of TTFB. Perceived load is under 100ms despite the full page taking 600ms.
Anti-Patterns
Wrapping everything in a single Suspense boundary. One large Suspense boundary behaves like buffered SSR — nothing streams until all data resolves. Use multiple Suspense boundaries around independent data sources.
Putting the LCP element inside a Suspense boundary. If the LCP element (hero image, main heading) is inside Suspense, it will not stream with the shell. LCP content must be in the shell for optimal Core Web Vitals.
Not providing meaningful fallbacks. Suspense fallbacks that are empty or just a spinner waste the opportunity to show a structural preview. Use skeleton screens that match the loaded content's layout and dimensions to prevent CLS.
Ignoring streaming errors. The onError callback in renderToPipeableStream fires for non-shell errors. Without logging and error handling, failed Suspense boundaries silently show fallbacks permanently. Monitor streaming errors to detect data source failures.
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- TTFB is under 100ms for the shell content (navigation, layout, LCP elements).
- Each independent data source has its own Suspense boundary for parallel streaming.
- LCP content is in the shell, not inside a Suspense boundary.
- Suspense fallbacks use skeleton screens that match loaded content dimensions.
- Streaming errors are logged and individual section failures do not crash the page.
1---2name: perf-streaming-rendering3description: Streaming Rendering4---5# Streaming Rendering67> Master streaming rendering — React Suspense-based streaming SSR, chunked transfer encoding, out-of-order HTML delivery, shell-first rendering, progressive page assembly, and error handling for streamed content to achieve the fastest possible Time to First Byte and progressive content delivery.89## When to Use1011- SSR blocks on slow data fetches, delaying TTFB for the entire page12- Users wait for the full page to render before seeing any content13- A page depends on multiple data sources with different response times14- Traditional SSR returns a blank screen until all data is fetched and rendered15- TTFB is high because the server waits for the slowest API before responding16- Above-fold content is ready quickly but below-fold content blocks the response17- A page mixes fast data (cached) with slow data (database, third-party API)18- Progressive enhancement requires content to appear incrementally19- React 18+ is available and streaming SSR can replace renderToString20- Server response is buffered entirely before sending, wasting time-to-first-byte potential2122## Instructions23241. **Understand streaming versus buffered SSR.** Traditional SSR buffers the entire HTML response. Streaming sends HTML chunks as they become ready:2526 ```27 Buffered SSR (renderToString):28 Server: [fetch all data...400ms] [render...100ms] [send complete HTML]29 Browser: [receive...parse...FCP]30 TTFB: 500ms | FCP: 600ms3132 Streaming SSR (renderToPipeableStream):33 Server: [render shell...20ms] [stream shell HTML] [fetch slow data...] [stream rest]34 Browser: [receive shell...FCP] [...progressive content]35 TTFB: 20ms | FCP: 100ms | Full content: 500ms36 ```37382. **Structure the page with Suspense boundaries for streaming.** Each Suspense boundary is a potential streaming point:3940 ```typescript41 // The server streams each Suspense boundary independently42 export default function DashboardPage() {43 return (44 <Shell>45 {/* Streams immediately — no data dependency */}46 <Header />47 <Navigation />4849 <div className="dashboard-grid">50 {/* Streams when metrics data resolves (~50ms) */}51 <Suspense fallback={<MetricsSkeleton />}>52 <MetricsPanel />53 </Suspense>5455 {/* Streams when chart data resolves (~200ms) */}56 <Suspense fallback={<ChartSkeleton />}>57 <RevenueChart />58 </Suspense>5960 {/* Streams when activity data resolves (~400ms) */}61 <Suspense fallback={<ActivitySkeleton />}>62 <RecentActivity />63 </Suspense>64 </div>65 </Shell>66 );67 }68 ```69703. **Implement streaming in a Node.js server.** Use renderToPipeableStream with onShellReady:7172 ```typescript73 import { renderToPipeableStream } from 'react-dom/server';7475 async function handleRequest(req: Request, res: Response) {76 const { pipe, abort } = renderToPipeableStream(77 <App url={req.url} />,78 {79 bootstrapScripts: ['/client.js'],8081 onShellReady() {82 // The app shell (everything outside Suspense) is ready83 // Start streaming immediately — don't wait for Suspense content84 res.statusCode = 200;85 res.setHeader('Content-Type', 'text/html; charset=utf-8');86 res.setHeader('Transfer-Encoding', 'chunked');87 pipe(res);88 },8990 onAllReady() {91 // Everything including Suspense content is ready92 // For crawlers/bots, wait for this instead of onShellReady93 },9495 onShellError(error) {96 // Shell failed to render — send error page97 res.statusCode = 500;98 res.end('<h1>Something went wrong</h1>');99 },100101 onError(error) {102 // Non-shell error — log but continue streaming103 console.error('Streaming error:', error);104 },105 }106 );107108 // Abort streaming after timeout109 setTimeout(() => abort(), 10000);110 }111 ```1121134. **Handle bot/crawler requests differently.** Detect bots via user-agent (`/bot|crawl|spider/i`). For users, call `pipe(res)` in `onShellReady` for fast TTFB. For bots, call `pipe(res)` in `onAllReady` so they receive complete HTML for SEO indexing.1141155. **Understand out-of-order streaming mechanics.** React streams HTML in order (shell first), then replaces Suspense fallbacks out-of-order as data resolves:116117 ```html118 <!-- Initial stream: shell + fallbacks -->119 <html>120 <body>121 <header>...</header>122 <div id="metrics">123 <!--$?--><template id="B:0"></template>124 <div class="skeleton">...</div>125 <!--/$-->126 </div>127 <div id="chart">128 <!--$?--><template id="B:1"></template>129 <div class="skeleton">...</div>130 <!--/$-->131 </div>132133 <!-- Streamed later when metrics resolve (out of order, before chart): -->134 <div hidden id="S:0"><div class="metrics-panel">Real metrics content...</div></div>135 <script>136 $RC('B:0', 'S:0');137 </script>138 <!-- $RC swaps the fallback template with the real content -->139140 <!-- Streamed even later when chart resolves: -->141 <div hidden id="S:1"><div class="chart">Real chart content...</div></div>142 <script>143 $RC('B:1', 'S:1');144 </script>145 </body>146 </html>147 ```1481496. **Implement error boundaries for streamed content.** Individual Suspense sections can fail without crashing the entire stream:150151 ```typescript152 // Error boundary catches failures in individual sections153 function StreamSection({ children, fallback }) {154 return (155 <ErrorBoundary fallback={<SectionError />}>156 <Suspense fallback={fallback}>157 {children}158 </Suspense>159 </ErrorBoundary>160 );161 }162163 function DashboardPage() {164 return (165 <Shell>166 <StreamSection fallback={<MetricsSkeleton />}>167 <MetricsPanel /> {/* If this fails, shows SectionError */}168 </StreamSection>169170 <StreamSection fallback={<ChartSkeleton />}>171 <RevenueChart /> {/* Independent — unaffected by metrics failure */}172 </StreamSection>173 </Shell>174 );175 }176 ```1771787. **Configure streaming in Next.js App Router.** Next.js App Router streams by default with loading.tsx files:179180 ```typescript181 // app/dashboard/loading.tsx — automatic Suspense boundary182 export default function DashboardLoading() {183 return <DashboardSkeleton />;184 }185186 // app/dashboard/page.tsx — async component triggers streaming187 export default async function Dashboard() {188 const metrics = await getMetrics(); // data fetch during streaming189 return <MetricsDisplay data={metrics} />;190 }191192 // Nested streaming with parallel data fetching:193 // app/dashboard/@metrics/page.tsx — streams independently194 // app/dashboard/@chart/page.tsx — streams independently195 // app/dashboard/layout.tsx — renders the slot composition196 ```197198## Details199200### Chunked Transfer Encoding201202Streaming SSR uses HTTP chunked transfer encoding (or HTTP/2 DATA frames). The server sends the response in chunks without knowing the total content length upfront. The browser incrementally parses and renders each chunk. This is a standard HTTP/1.1 feature (Transfer-Encoding: chunked) and is native to HTTP/2 and HTTP/3. No special client-side code is needed — browsers have always supported incremental HTML parsing.203204### Shell Content Selection205206The "shell" is everything outside Suspense boundaries. Choosing what goes in the shell is a critical design decision. Shell content should be: (1) immediately available (no data fetching), (2) visually meaningful (layout, navigation, headers), (3) sufficient for LCP (if LCP is text-based). Shell content should NOT include: data-dependent content, personalized content, or content that requires slow API calls.207208### Worked Example: Vercel Dashboard209210Five Suspense boundaries: navigation (shell, instant), project list (~30ms from cache), deployment status (~100ms from API), analytics (~300ms), team activity (~500ms). Users see navigation and project list within 50ms of TTFB. Perceived load is under 100ms despite the full page taking 600ms.211212### Anti-Patterns213214**Wrapping everything in a single Suspense boundary.** One large Suspense boundary behaves like buffered SSR — nothing streams until all data resolves. Use multiple Suspense boundaries around independent data sources.215216**Putting the LCP element inside a Suspense boundary.** If the LCP element (hero image, main heading) is inside Suspense, it will not stream with the shell. LCP content must be in the shell for optimal Core Web Vitals.217218**Not providing meaningful fallbacks.** Suspense fallbacks that are empty or just a spinner waste the opportunity to show a structural preview. Use skeleton screens that match the loaded content's layout and dimensions to prevent CLS.219220**Ignoring streaming errors.** The onError callback in renderToPipeableStream fires for non-shell errors. Without logging and error handling, failed Suspense boundaries silently show fallbacks permanently. Monitor streaming errors to detect data source failures.221222## Source223224- React: Streaming SSR — https://react.dev/reference/react-dom/server/renderToPipeableStream225- Next.js: Streaming — https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming226- Dan Abramov: "New Suspense SSR Architecture" — https://github.com/reactwg/react-18/discussions/37227- HTTP Chunked Transfer Encoding — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding228229## Process2302311. Read the instructions and examples in this document.2322. Apply the patterns to your implementation, adapting to your specific context.2333. Verify your implementation against the details and edge cases listed above.234235## Harness Integration236237- **Type:** knowledge — this skill is a reference document, not a procedural workflow.238- **No tools or state** — consumed as context by other skills and agents.239240## Success Criteria241242- TTFB is under 100ms for the shell content (navigation, layout, LCP elements).243- Each independent data source has its own Suspense boundary for parallel streaming.244- LCP content is in the shell, not inside a Suspense boundary.245- Suspense fallbacks use skeleton screens that match loaded content dimensions.246- Streaming errors are logged and individual section failures do not crash the page.