Streaming and Loading UI in Next.js

Streaming lets you send HTML to the client in chunks as they become ready. Instead of waiting for all requests to finish, the user immediately sees part of the page while the rest loads.

The Problem Without Streaming

Without streaming all HTML is generated before sending:

1

All data loads on the server

Database query for problems, API call for stats, profile fetch. All sequential or parallel, but HTML is sent only when everything is ready.

2

User waits

White screen until the server assembles the entire page. TTFB can be several seconds.

How Streaming Solves It

With streaming Next.js sends page parts as they become ready:

1

Instant response

HTML with layout, navigation and skeletons is sent immediately.

2

Progressive loading

Data loads progressively. Each block replaces its skeleton.

loading.tsx

The simplest way to add loading UI. Next.js automatically wraps page.tsx in React Suspense with the fallback from loading.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>
  )
}

The user sees the skeleton immediately while content appears as data loads.

Suspense for Granular Control

For finer control use Suspense directly:

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
      <Suspense fallback={<ProgressSkeleton />}>
        <RoadmapProgress />
      </Suspense>
    </div>
  )
}

Each Suspense boundary streams independently. UserStats can appear before RecentActivity.

How It Works Under the Hood

// 1. Server sends HTML with fallbacks
<div>
  <h1>Dashboard</h1>
  <div id="stats"><!-- StatsSkeleton --></div>
  <div id="activity"><!-- ActivitySkeleton --></div>
</div>

// 2. When data is ready, server sends a <script>
// that replaces the fallback with real content
<script>
  $RC("stats", "<div>Problems solved: 42</div>")
</script>

The browser receives a stream of data over a single HTTP connection.

Pattern: Async Server Component

Suspense works with async server components:

// This component will be streamed
async function UserStats() {
  const stats = await db.user.getStats(userId)
  // The query might take 2 seconds
  // The user sees a skeleton while data loads

  return (
    <div>
      <p>Problems solved: {stats.problemsSolved}</p>
      <p>Articles read: {stats.docsRead}</p>
    </div>
  )
}

loading.tsx vs Suspense

loading.tsxSuspense
ScopeEntire page (page.tsx)Specific component
GranularityOne fallback per pageMultiple independent
SetupAutomaticManual

Practical tip:

Use loading.tsx for simple pages with a single data block. For dashboards and complex pages use Suspense for each block separately so content appears as it becomes ready.

Useful Resources