---
title: "08 — Loading, Streaming, and Error States — Handling the Slow and the Broken"
uid: loading-streaming-errors
tags: ["roadmap:nextjs", "streaming", "loading", "error-handling", "suspense", "nextjs"]
excerpt: "Three files handle the async and the broken: loading.tsx shows an instant placeholder, streaming sends HTML as it's ready, error.tsx catches the rest."
date: 2026-08-13T03:28:01+0000
source: https://www.aveshina.my.id/en/blog/loading-streaming-errors
---

"This takes time" and "this threw" are the two moments every route eventually hits, and the App Router ships three conventions for them. The model that clicked: **loading.tsx is the instant placeholder Suspense swaps in while a route segment loads; streaming sends HTML chunks to the browser as they're ready instead of waiting for the whole page; error.tsx is the boundary that catches what breaks** [1][2][3]. Together they're how the App Router handles "this takes time" and "this threw."

## loading.tsx — the instant placeholder

The special file loading.tsx creates a meaningful loading UI powered by React Suspense [1]. The behavior is the part worth internalizing: the moment a route segment starts loading, the server instantly sends the loading UI — *before* the slow work (a database query, a fetch) finishes. When the real content is ready, it's swapped in automatically.

```
// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <Skeleton />; // shown instantly while the page's data loads
}
```

The payoff is perceived performance. The user sees *something* immediately instead of staring at a blank screen while the server waits on a slow query. And because each route segment can have its own loading.tsx, the granularity is fine — a slow blog post doesn't block the rest of the layout from rendering.

## Streaming — HTML as it becomes ready

Streaming is the mechanism underneath. Instead of the server waiting for the entire page's data to resolve before sending anything, it sends HTML in chunks as each piece becomes available [1][4]. Suspense boundaries mark the seams: the server streams the static shell and the loading placeholders immediately, then fills in the dynamic content when it's ready.

The way of thinking that helped: think of the page as a tree of Suspense boundaries. Each boundary can resolve independently and stream its result when ready. The slow parts don't block the fast parts.

```
// wrapping a slow component in a Suspense boundary manually
import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      <Header /> {/* static, renders immediately */}
      <Suspense fallback={<p>Loading posts…</p>}>
        <SlowPostsList /> {/* streams in when ready */}
      </Suspense>
    </>
  );
}
```

The roadmap is clear that this works on both Node.js and Edge runtimes — streaming isn't a Node-only feature in the App Router [1].

```figure
<svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Without streaming vs with streaming. Top: without streaming, the browser waits until the whole page (including a slow query) is ready, then receives one big HTML blob. Bottom: with streaming, the server sends the shell + loading placeholder immediately, then streams the dynamic content in when ready.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- No streaming -->
    <text x="360" y="24" font-size="12" font-weight="700" fill="#7f1d1d" text-anchor="middle">Without streaming — wait, then one blob</text>
    <rect x="40" y="40" width="80" height="36" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="1"/>
    <text x="80" y="62" font-size="10" fill="#7f1d1d" text-anchor="middle">browser</text>
    <line x1="120" y1="58" x2="240" y2="58" stroke="#dc2626" stroke-width="1.5" stroke-dasharray="4 3"/>
    <text x="180" y="50" font-size="9" fill="#7f1d1d" text-anchor="middle">waits…</text>
    <rect x="240" y="40" width="120" height="36" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="1"/>
    <text x="300" y="62" font-size="10" fill="#7f1d1d" text-anchor="middle">server (slow)</text>
    <line x1="360" y1="58" x2="480" y2="58" stroke="#64748b" stroke-width="1.5"/>
    <rect x="480" y="40" width="100" height="36" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="1"/>
    <text x="530" y="62" font-size="10" fill="#7f1d1d" text-anchor="middle">full HTML</text>
    <text x="640" y="62" font-size="10" font-style="italic" fill="#7f1d1d" text-anchor="middle">late first paint</text>

    <!-- With streaming -->
    <text x="360" y="140" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">With streaming — shell now, rest when ready</text>
    <rect x="40" y="156" width="80" height="36" rx="6" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
    <text x="80" y="178" font-size="10" fill="#052e16" text-anchor="middle">browser</text>
    <line x1="120" y1="174" x2="240" y2="174" stroke="#16a34a" stroke-width="1.5"/>
    <text x="180" y="166" font-size="9" fill="#052e16" text-anchor="middle">shell + placeholder</text>
    <rect x="240" y="156" width="120" height="36" rx="6" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
    <text x="300" y="178" font-size="10" fill="#052e16" text-anchor="middle">server</text>
    <line x1="360" y1="174" x2="480" y2="174" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4 3"/>
    <text x="420" y="166" font-size="9" fill="#052e16" text-anchor="middle">…later, dynamic chunk</text>
    <rect x="480" y="156" width="100" height="36" rx="6" fill="#fef9c3" stroke="#ca8a04" stroke-width="1"/>
    <text x="530" y="178" font-size="10" fill="#422006" text-anchor="middle">rest streams</text>
    <text x="640" y="178" font-size="10" font-style="italic" fill="#052e16" text-anchor="middle">instant first paint</text>

    <text x="360" y="252" font-size="11" font-style="italic" fill="#64748b" text-anchor="middle">Suspense boundaries are the seams — each chunk streams when its data resolves</text>
  </g>
</svg>
```

## error.tsx — the recovery boundary

Errors are the other thing every route has to handle, and the App Router splits them into two categories [2]:

- **Expected errors** — things that happen during normal operation, like a failed form submission or a validation error. These should be handled explicitly in the code and returned to the client gracefully.
- **Uncaught exceptions** — unexpected bugs that shouldn't occur in normal flow. These are caught by an **error boundary**, which is what error.tsx creates.

```
// app/blog/[slug]/error.tsx — must be a client component
'use client';

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>Something broke: {error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
```

The boundary isolates the failure: a thrown error in a route segment renders its error.tsx instead of crashing the whole page. The reset function lets the user attempt to re-render the segment. Crucially, error boundaries must be Client Components, because React's error-boundary mechanism relies on client-side state.

## How I use this

For any route that does async work, I add a loading.tsx so the user gets an instant placeholder instead of a blank screen — perceived performance is the whole game. For anything that can throw (and most code can), I add an error.tsx so a failure in one segment doesn't blank the whole page. And for slow sub-components inside a page, I wrap them in explicit <Suspense> boundaries so the fast parts render first. The pattern is: cover the async with loading, cover the broken with error, and let streaming do the rest.

## References

[1] Vercel, "Loading UI and streaming," Next.js Docs, 2024. [Online]. Available: [https://nextjs.org/docs/14/app/building-your-application/routing/loading-ui-and-streaming](https://nextjs.org/docs/14/app/building-your-application/routing/loading-ui-and-streaming)

[2] Vercel, "Error handling," Next.js Docs, 2024. [Online]. Available: [https://nextjs.org/docs/app/getting-started/error-handling#handling-expected-errors](https://nextjs.org/docs/app/getting-started/error-handling#handling-expected-errors)

[3] React, "Suspense," React Docs, 2024. [Online]. Available: [https://react.dev/reference/react/Suspense](https://react.dev/reference/react/Suspense)

[4] "Next.js 15 tutorial — Streaming," YouTube, 2024. [Video]. Available: [https://www.youtube.com/watch?v=oSf1gUDGJOA](https://www.youtube.com/watch?v=oSf1gUDGJOA)

[5] "Next.js 15 tutorial — Error handling," YouTube, 2024. [Video]. Available: [https://www.youtube.com/watch?v=fWV5WPSbgdg](https://www.youtube.com/watch?v=fWV5WPSbgdg)

```quiz
Q: What does a loading.tsx file create?
- A full-page spinner that blocks rendering
- An instant Suspense fallback shown while the route segment's content loads, swapped out when ready
correct: 1
explain: loading.tsx creates a Suspense-powered loading UI. The server sends it immediately, then swaps in the real content when the segment's data resolves.

Q: Streaming in the App Router means…
- the client streams keystrokes to the server
- the server sends HTML chunks as they become ready, instead of waiting for the whole page
correct: 1
explain: Streaming sends the static shell and placeholders first, then fills in dynamic content as each Suspense boundary resolves. Slow parts don't block fast parts.

Q: Error boundaries created by error.tsx must be…
- Server Components
- Client Components
correct: 1
explain: React's error-boundary mechanism relies on client-side state, so error.tsx files must use the 'use client' directive.

Q: The two categories of errors the App Router distinguishes are…
- recoverable and unrecoverable
- expected errors (handled explicitly) and uncaught exceptions (caught by error boundaries)
correct: 1
explain: Expected errors like failed validation are returned gracefully. Uncaught exceptions are caught by the error.tsx boundary, which isolates the failure to that segment.
```
