deep-dive2023-07-05·12 min·346/348

Using Suspense Boundaries Effectively in Next.js

How to leverage React Suspense boundaries in Next.js App Router for streaming, loading states, and progressive rendering

Using Suspense Boundaries Effectively in Next.js

Introduction

Suspense boundaries are one of the most powerful patterns in React Server Components. They allow you to progressively render your UI, showing content as soon as it is available rather than waiting for the slowest data fetch. In Next.js App Router, Suspense is the primary mechanism for managing loading states at the component level.

After optimizing a dashboard that previously waited 3 seconds for all data to load before showing anything, I used Suspense to reduce the perceived load time to under 500ms by streaming each section independently.

Environment

  • OS: Windows 11
  • Node.js: v20.10.0
  • Next.js: 14.0.4
  • React: 18.2.0

Problem

Without Suspense, the entire page waits for the slowest data fetch:

// app/dashboard/page.tsx
// BAD: Everything waits for the slowest fetch
export default async function Dashboard() {
  const user = await getUser()           // 100ms
  const analytics = await getAnalytics() // 2000ms
  const activity = await getActivity()   // 500ms

  return (
    
) } // Total time: 2600ms (user sees nothing until everything loads)

The browser received the complete HTML after 2.6 seconds. During this time, the user sees a blank white page.

Analysis

The issue is sequential data fetching. Each await in a Server Component blocks rendering of everything below it. Suspense allows you to declare independent sections that can load and render independently.

The key concepts:

1. Suspense boundaries define independent loading sections. Content outside a Suspense boundary waits for all async operations within it.

2. loading.tsx is a shorthand for Suspense. The file-based loading state wraps the entire page in a Suspense boundary.

3. Server-side streaming. When a Suspense boundary resolves, the server sends the HTML chunk to the client immediately, before other sections finish loading.

4. Client-side hydration. Each streamed section is hydrated independently, so interactivity becomes available section by section.

Solution

Pattern 1: Independent streaming sections

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

export default function Dashboard() {
  return (
    
{/* User header loads immediately - no Suspense needed if fast */} {/* Analytics loads independently */} }> {/* Activity feed loads independently */} }>
) } // Server Component that fetches data async function AnalyticsSection() { const analytics = await getAnalytics() // This takes 2 seconds return } async function ActivitySection() { const activity = await getActivity() // This takes 500ms return }

Pattern 2: Nested Suspense for fine-grained loading

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

export default function AnalyticsPage() {
  return (
    

Analytics Dashboard

}> }> }>
}>
) } async function RevenueMetric() { const revenue = await fetchRevenue() return (

Revenue

${revenue.total.toLocaleString()}

+{revenue.growth}%

) }

Pattern 3: Suspense with error boundaries

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

export default function Dashboard() {
  return (
    
Failed to load analytics
}> }> Failed to load activity
}> }>
) }

Pattern 4: Client Component with Suspense

// components/InteractiveDashboard.tsx
'use client'

import { Suspense, lazy } from 'react'

// Lazy load heavy client components
const HeavyChart = lazy(() => import('./HeavyChart'))
const DataGrid = lazy(() => import('./DataGrid'))

export function InteractiveDashboard() {
  return (
    
Loading chart...
}> Loading data grid...
}>
) }

Pattern 5: Route-level loading with loading.tsx

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    
{[1, 2, 3].map((i) => (
))}
) }

Lessons Learned

  1. Place Suspense boundaries around slow data fetches, not fast ones. If a component loads in under 100ms, wrapping it in Suspense adds unnecessary overhead.

  2. Use unique keys in fallback components. React needs to distinguish between different Suspense boundaries. Using the same skeleton component without keys can cause reconciliation issues.

  3. Combine Suspense with Error Boundaries. A failed data fetch inside Suspense will trigger the nearest Error Boundary, not the Suspense fallback.

  4. Stream HTML progressively. Suspense enables streaming, where the server sends HTML chunks as they become available. This dramatically improves Time to First Byte.

  5. Test the loading experience on slow connections. Use Chrome DevTools network throttling to verify that your Suspense boundaries provide meaningful feedback on slow networks.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.

Previous
pydantic 모델 검증 에러 해결
Next
Ignoring TypeScript Errors During Next.js Build
Back to Blog