Suggest an editImprove this articleRefine the answer for “Streaming and loading UI in Next.js”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Streaming in Next.js** sends HTML to the browser in chunks as each part becomes ready, reducing TTFB instead of waiting for all data. ```tsx // app/dashboard/loading.tsx export default function Loading() { return <div className="h-16 bg-neutral-200 animate-pulse rounded-lg" /> } ``` Use `loading.tsx` for simple pages. For dashboards with multiple data sources, wrap each slow component in `Suspense` so sections appear independently. **Key:** `loading.tsx` = one fallback for the whole route. `Suspense` = per-component streaming control.Shown above the full answer for quick recall.Answer (EN)Image**Streaming in Next.js** sends HTML to the browser in chunks as each piece becomes ready, so users see content progressively instead of staring at a blank screen. ## Theory ### TL;DR - Without streaming: server waits for all data, then sends the full HTML. TTFB can hit several seconds. - With streaming: layout and skeletons arrive instantly, data fills in as server queries finish. - `loading.tsx` = one fallback for the whole page, zero extra setup. - `Suspense` = independent fallbacks per component, requires manual wrapping. - Under the hood: one HTTP connection stays open; the server flushes chunks via small `<script>` tags. ### Quick example ```tsx // app/problems/loading.tsx // Next.js picks this up automatically — no extra imports needed export default function Loading() { return ( <div className="space-y-4"> {[...Array(5)].map((_, i) => ( <div key={i} className="h-16 bg-neutral-200 animate-pulse rounded-lg" /> ))} </div> ) } ``` Drop this file next to `page.tsx`. Next.js wraps the page in a `Suspense` boundary with this component as the fallback. That is the whole setup. ### Why traditional SSR blocks the page A typical dashboard hits three data sources: a database query for user stats, an API call for recent activity, a second query for roadmap progress. Even if you run them in parallel with `Promise.all`, the server still waits for the slowest one before sending a single byte of HTML. That slowest query sets your TTFB. One slow dependency holds back the whole page. ### How streaming changes the timing Next.js sends the shell of the page (layout, navigation, skeleton placeholders) as soon as React starts rendering. Each `Suspense` boundary acts as a flush point. When the data behind that boundary resolves, the server sends a small script that swaps the skeleton for real content. The user sees something useful in under 200ms. The heavy queries finish in the background. ### Suspense for granular control `loading.tsx` treats the entire page as one boundary. That works for simple pages with a single data source. For dashboards, one slow query blocks the whole skeleton approach. Wrap each slow component in its own `Suspense`: ```tsx // app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<StatsSkeleton />}> <UserStats /> {/* 2s query */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* 800ms query */} </Suspense> <Suspense fallback={<ProgressSkeleton />}> <RoadmapProgress /> {/* 1.5s query */} </Suspense> </div> ) } ``` `RecentActivity` finishes first and renders at ~800ms. `UserStats` and `RoadmapProgress` appear when their queries resolve. No boundary waits for another. ### How it works under the hood The browser opens one HTTP connection to the server. The server sends the initial HTML with skeleton placeholders, then keeps that connection alive. When a query resolves, the server flushes a `<script>` tag: ```tsx // 1. Initial HTML the browser receives <div id="$stats"><!-- StatsSkeleton HTML --></div> // 2. Server flushes this chunk ~800ms later <div hidden id="S:1"> <div>Problems solved: 42</div> </div> <script>$RC("$stats", "S:1")</script> ``` `$RC` is React's internal function for replacing the placeholder. The browser never makes a second request. I have watched this in the Network tab: one long-lived request, multiple data chunks arriving at different timestamps. ### loading.tsx vs Suspense | | loading.tsx | Suspense | |---|---|---| | Scope | Entire page (page.tsx) | Single component | | Granularity | One fallback for the page | Multiple independent fallbacks | | Setup | Automatic (file convention) | Manual wrapping | | Best for | Simple pages, single data source | Dashboards, multiple slow queries | Both use the same mechanism internally. `loading.tsx` is a file-system shortcut that Next.js converts to a `Suspense` boundary at the route level. ### Common mistakes **Wrapping everything in one boundary.** A single `Suspense` around the whole dashboard puts you back to square one. Fast components wait for slow ones. ```tsx // Bad: one boundary, one wait for everything <Suspense fallback={<DashboardSkeleton />}> <UserStats /> <RecentActivity /> <RoadmapProgress /> </Suspense> // Better: each component streams independently <Suspense fallback={<StatsSkeleton />}><UserStats /></Suspense> <Suspense fallback={<ActivitySkeleton />}><RecentActivity /></Suspense> <Suspense fallback={<ProgressSkeleton />}><RoadmapProgress /></Suspense> ``` **Using `loading.tsx` for dashboards.** It covers the whole route. If one block takes 3 seconds, the user sees the full skeleton for 3 seconds even though two other blocks were ready at 500ms. **Streaming with client components.** The pattern only works when the component itself awaits data on the server: ```tsx // This streams correctly - async server component async function UserStats() { const stats = await db.user.getStats(userId) return <div>{stats.problemsSolved}</div> } // This does not stream - client component fetches after hydration 'use client' function UserStats() { const [stats, setStats] = useState(null) useEffect(() => { fetch('/api/stats').then(r => r.json()).then(setStats) }, []) return stats ? <div>{stats.problemsSolved}</div> : null } ``` The client component version causes a waterfall: page loads, hydrates, then fetches. No skeleton during the actual data load. ### Real-world usage - Product dashboards: each widget gets its own `Suspense` boundary, the shell appears instantly. - Blog with comments: article renders immediately, comment section loads in the background. - E-commerce product page: product details stream first, reviews load independently. - Any route where one query is slower than the rest: isolate that component behind `Suspense`. ### Follow-up questions **Q:** Does streaming work with `getServerSideProps`? **A:** No. Streaming requires the App Router. `getServerSideProps` is part of the Pages Router and does not support `Suspense` boundaries in the same way. **Q:** What happens if an async component throws an error during streaming? **A:** React looks for the nearest `error.tsx` boundary. If you define one next to `page.tsx`, it catches the error. Without it, the error can corrupt the already-sent shell in the browser. **Q:** Can you nest `Suspense` boundaries? **A:** Yes. Inner boundaries resolve and stream first. Outer boundaries catch components that have no inner boundary. The nesting is unlimited but keep it intentional. **Q:** How does streaming affect SEO? **A:** Googlebot handles streaming well. The full HTML is present in the response body, just delivered in chunks. Server-rendered content streamed this way is indexable. **Q:** What is the difference between streaming and ISR? **A:** ISR caches rendered HTML and revalidates periodically, which reduces server load. Streaming generates fresh HTML on every request but sends it progressively, which reduces TTFB for dynamic pages. They solve different problems. ## Examples ### Basic: loading.tsx for a problem list ```tsx // app/problems/loading.tsx export default function Loading() { return ( <div className="space-y-4"> {[...Array(5)].map((_, i) => ( <div key={i} className="h-16 bg-neutral-200 animate-pulse rounded-lg" /> ))} </div> ) } // app/problems/page.tsx export default async function ProblemsPage() { const problems = await db.problems.findMany() // might take 1-2s return ( <ul> {problems.map(p => <li key={p.id}>{p.title}</li>)} </ul> ) } ``` The user sees five skeleton bars immediately. When the database query returns, the real list replaces them. No extra wiring required. ### Intermediate: dashboard with independent boundaries ```tsx // app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div className="grid gap-6"> <Suspense fallback={<StatsSkeleton />}> <UserStats /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> </Suspense> </div> ) } async function UserStats() { const stats = await db.users.getStats() // 2s return <div>Problems solved: {stats.count}</div> } async function RecentActivity() { const activity = await db.activity.recent() // 500ms return <ul>{activity.map(a => <li key={a.id}>{a.label}</li>)}</ul> } ``` `RecentActivity` appears at ~500ms. `UserStats` appears at ~2s. Without separate boundaries, both would wait the full 2 seconds. ### Senior: error handling during streaming ```tsx // app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error reset: () => void }) { return ( <div> <p>Failed to load this section.</p> <button onClick={reset}>Try again</button> </div> ) } // app/dashboard/page.tsx export default function DashboardPage() { return ( <div> <Suspense fallback={<StatsSkeleton />}> <UserStats /> {/* if this throws, error.tsx catches it */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* renders normally regardless */} </Suspense> </div> ) } ``` If `UserStats` throws during streaming, `error.tsx` catches it and shows a retry button. `RecentActivity` is in a separate boundary and renders normally. One failed query does not break the rest of the page.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.