Streaming & Suspense
Lazy rendering eliminates the need for users to stare at a blank screen while waiting—the page is displayed as it is being generated, reducing the time to first byte (TTFB) by 60%.
1. What You'll Learn
- Suspense Boundary Splitting Strategy (Page → Skeleton Screen → Content)
loading.tsxFile Agreements and Automatic Suspense Packages- Design of Streaming Load Order and Nested Suspense Fallbacks
- React 19
use()Hooks: Consuming Promises in Components - AI SDK
streamTextGenerates Text with a Typewriter Effect - A hybrid mode combining stream rendering and PPR static shells
2. A True Story of a Front-End Architect
(1) Pain Point: The waterfall of requests makes the page load three times slower
Charlie is a front-end architect on the TaskFlow team. He noticed that the Dashboard page takes 4.2 seconds to load:
"The page contains 5 data cards, 1 activity list, and 1 statistical chart. All data is loaded sequentially
awaitwithin the page components; a single slow API call can bring the entire page to a standstill—leaving users staring at a blank screen, waiting idly."
| Issue | Duration | Cause |
|---|---|---|
| User list (200 ms) + Number of items (300 ms) | 500 ms | Serial wait |
| Statistical Charts (800 ms) | 800 ms | Slow backend processing |
| Activity Flow (400 ms) | 400 ms | Cross-Service Queries |
| Total TTFP | 1,700 ms | All serial |
(2) The Streaming + Suspense Solution
Split the page into multiple Suspense boundaries, with each data block loaded independently via streaming.
export default function DashboardPage() {
return (
<div>
<h1>Overview</h1>
<Suspense fallback={<SkeletonCards />}>
<UserCards />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
)
}
(3) Revenue
| Dimension | Before Optimization (Serial) | After Optimization (Streaming) |
|---|---|---|
| First Byte Time | 1,700 ms | 50 ms (static shell) |
| First-screen interactivity | 4.2s | 1.1s |
| Large-block blocking | All | ✅ None |
| User Experience | Blank screen for 4 seconds | Skeleton screen → Fills in block by block |
3. Suspense Boundary Splitting Strategy
(1) The Three Loading Layers of a Page
graph TB
A[Page] --> B[Layer 1: Static shell<br/>layout + header<br/>Instant Preview]
A --> C[Layer 2: Wireframe Display<br/>loading.tsx<br/>~200ms]
A --> D[Layer 3: Content<br/>Suspense Boundary<br/>Block-by-Block Streaming Arrival]
B --> E[Users see the page structure]
C --> F[Users see a placeholder animation]
D --> G[Fill in the content in order]
style B fill:#d4edda
style C fill:#fff3cd
style D fill:#cce5ff
| Layer | Mechanism | Display Time | User Perception |
|---|---|---|---|
| Static Shell | Layout | Real-time | Page Framework |
| Skeleton Screen | loading.tsx |
~200 ms | Loading animation |
| Content | Suspense + fallback | Block-by-block rendering | Progressive loading |
(2) Serial vs. Parallel Data Loading
// ❌ Serial Waterfall Chart — Slow
export default async function SlowPage() {
const users = await fetch('https://api.example.com/users').then(r => r.json())
const projects = await fetch('https://api.example.com/projects').then(r => r.json())
const analytics = await fetch('https://api.example.com/analytics').then(r => r.json())
return <Dashboard users={users} projects={projects} analytics={analytics} />
}
// ✅ Parallel Suspense — Fast
export default function FastPage() {
return (
<div>
<Suspense fallback={<SkeletonCards />}><UserCards /></Suspense>
<Suspense fallback={<SkeletonCards />}><ProjectCards /></Suspense>
<Suspense fallback={<ChartSkeleton />}><AnalyticsChart /></Suspense>
</div>
)
}
▶ Example: Implementing the Suspense component
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }> | }>
// components/UserCards.tsx — Independent Suspense Boundary
export default async function UserCards() {
// Simulating Slow Queries
const users = await new Promise<{ name: string; email: string }[]>((resolve) =>
setTimeout(() => resolve([
{ name: 'Alice', email: 'alice@taskflow.io' },
{ name: 'Bob', email: 'bob@taskflow.io' },
{ name: 'Charlie', email: 'charlie@taskflow.io' }
]), 2000)
)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
{users.map((u) => (
<div key={u.email} style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: 8 }}>
<h3>{u.name}</h3>
<p>{u.email}</p>
</div>
))}
</div>
)
}
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
// components/SkeletonCards.tsx — Wireframe Display fallback
export default function SkeletonCards() {
return (
<div style={{ display: 'flex', gap: '1rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{
width: 200, height: 100, borderRadius: 8,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite'
}} />
))}
</div>
)
}
4. loading.tsx and Automatic Suspense
(1) Document Provisions
| File | Purpose | Suspense Scope |
|---|---|---|
app/dashboard/loading.tsx |
Wrap the entire page route | page.tsx All content |
app/dashboard/settings/loading.tsx |
settings segment only | settings/page.tsx |
graph TB
A[app/dashboard/] --> B[layout.tsx<br/>Root Layout]
A --> C[loading.tsx<br/>Page-level Suspense]
A --> D[page.tsx<br/>Page Content]
D --> E{Within the page}
E --> F[<Suspense><SlowWidget/></Suspense>]
E --> G[<Suspense><AnotherWidget/></Suspense>]
C --> H[Display loading.tsx fallback]
D --> I[Display page.tsx Content]
F --> J[Independent Streaming Loading]
style C fill:#fff3cd
style F fill:#cce5ff
style G fill:#cce5ff
(2) Design of loading.tsx
// app/dashboard/loading.tsx — Page-Level Skeleton Screen
export default function DashboardLoading() {
return (
<div style={{ padding: '2rem' }}>
<div style={{ height: 32, width: 200, background: '#eee', borderRadius: 4, marginBottom: '2rem' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{ height: 120, background: '#f5f5f5', borderRadius: 8 }} />
))}
</div>
<div style={{ height: 300, background: '#f5f5f5', borderRadius: 8, marginTop: '2rem' }} />
</div>
)
}
▶ Example: Nested loading.tsx hierarchy
app/dashboard/ ← loading.tsx Full-page
├── layout.tsx ← Navigation Bar(Instant Preview)
├── loading.tsx ← Page Skeleton Screen
├── page.tsx ← Dashboard Content
├── projects/ ← Subroutine
│ ├── loading.tsx ← Skeleton screen for the project list only
│ └── page.tsx ← Project List
└── settings/
└── loading.tsx ← Set-up Skeleton Screen
// app/dashboard/projects/loading.tsx — Loading state for the project list only
export default function ProjectsLoading() {
return (
<div>
{[1, 2, 3, 4].map((i) => (
<div key={i} style={{
height: 64, marginBottom: 8, borderRadius: 6,
background: 'linear-gradient(90deg, #e8e8e8 0%, #f5f5f5 50%, #e8e8e8 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s ease-in-out infinite'
}} />
))}
</div>
)
}
5. Designing Nested Suspense and Fallback
(1) Nested Strategies
graph TB
A[Page] --> B[Outer layer Suspense<br/>fallback: Page Structure]
A --> C[Inner layer Suspense 1<br/>fallback: Card Template]
A --> D[Inner layer Suspense 2<br/>fallback: Chart Framework]
D --> E[At a deeper level Suspense<br/>fallback: Micro-framework]
B -->|View Now| F[Page Structure]
C -->|~500ms| G[User Card]
D -->|~800ms| H[Statistical Charts]
E -->|~1200ms| I[Chart Details]
style B fill:#f8d7da
style C fill:#fff3cd
style D fill:#cce5ff
style E fill:#d4edda
| Nesting Level | Recommended Fallback | Display Time | Information Density |
|---|---|---|---|
| Outer Layer (Page) | Large Placeholder | Instant | Low (Structure) |
| Middle Layer (Component) | Component Shape Skeleton | ~500 ms | Middle (Outline) |
| Inner Layer (Details) | Small Placeholder + Micro-animation | ~1200 ms | Top (Content) |
▶ Example: Three-level nested Suspense
Output:
Diagram of nested Suspense: outer fallback (page shell) + inner fallback (section loading).
// app/analytics/page.tsx — Nested Suspense Practical Application
import { Suspense } from 'react'
function SummarySkeleton() { return <div style={{ height: 100, background: '#eee' }} /> }
function ChartSkeleton() { return <div style={{ height: 300, background: '#f5f5f5' }} /> }
function DetailSkeleton() { return <div style={{ height: 60, background: '#fafafa' }} /> }
export default function AnalyticsPage() {
return (
<div>
<h1>Analysis Report</h1>
{/* Outer layer:Overview Card */}
<Suspense fallback={<SummarySkeleton />}>
<SummaryCards />
</Suspense>
{/* Middle Management:Charts */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Inner layer:List of Details */}
<Suspense fallback={<DetailSkeleton />}>
<TopProjects />
</Suspense>
</div>
)
}
async function SummaryCards() {
await new Promise((r) => setTimeout(r, 500))
return <div>Income for This Month: $120,000 • Number of users: 15,230 • Project: 342</div>
}
async function RevenueChart() {
await new Promise((r) => setTimeout(r, 1000))
return <div style={{ height: 300, background: '#e8f4f8' }}>[Charts: Monthly Revenue Trends]</div>
}
async function TopProjects() {
await new Promise((r) => setTimeout(r, 1500))
return <div>Top Project: TaskFlow (45%), WebApp (30%), Mobile (25%)</div>
}
Output:
Renders static shell immediately. Dynamic sections show "Loading..." fallback until data loads.
Visible content: Analysis Report | }> | }>
6. React 19 use() Hook for Reading Promises
Comparison of use() vs await
| Feature | await (Server Component) |
use() (Client Component) |
|---|---|---|
| Location | Server Component only | Client Component (including 'use client') |
| Blocking behavior | Blocking component rendering | Throwing a Promise → Suspense catches it |
| Type Signature | const data = await promise |
const data = use(promise) |
| Resubmit | Automatic (RSC re-execution) | Must be triggered manually |
// ✅ Server Component: await
async function ServerProfile({ id }: { id: string }) {
const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json())
return <div>{user.name}</div>
}
// ✅ Client Component: use()
'use client'
import { use } from 'react'
function ClientProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <div>{user.name}</div>
}
(2) use() Using Streaming in the Client Component
// components/StreamingProfile.tsx — use() + Suspense
'use client'
import { use } from 'react'
interface User { name: string; email: string; bio: string }
function UserProfile({ promise }: { promise: Promise<User> }) {
const user = use(promise)
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.bio}</p>
</div>
)
}
// Use on the page
import { Suspense } from 'react'
export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) // params Me too. Promise
const userPromise = fetch(`https://api.example.com/users/${id}`).then(r => r.json())
return (
<Suspense fallback={<div>Loading user profile...</div>}>
<UserProfile promise={userPromise} />
</Suspense>
)
}
params and searchParams are Promises; you must unwrap them using await (RSC) or use() (Client).
▶ Example: use() Implementing an infinite scroll feed
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
// components/InfinitePosts.tsx
'use client'
import { use, useState, useTransition } from 'react'
interface Post { id: number; title: string }
async function fetchPosts(page: number): Promise<Post[]> {
const res = await fetch(`/api/posts?page=${page}&limit=10`)
return res.json()
}
export default function InfinitePosts({ initialPromise }: { initialPromise: Promise<Post[]> }) {
const [page, setPage] = useState(1)
const [postsPromise, setPostsPromise] = useState(initialPromise)
const [isPending, startTransition] = useTransition()
const posts = use(postsPromise)
const loadMore = () => {
startTransition(() => {
setPage((p) => p + 1)
setPostsPromise(fetchPosts(page + 1))
})
}
return (
<div>
{posts.map((post) => <div key={post.id}>{post.title}</div>)}
<button onClick={loadMore} disabled={isPending}>
{isPending ? 'Loading......' : 'Load More'}
</button>
</div>
)
}
Output:
Renders: InfinitePosts page with interactive UI elements.
7. AI SDK StreamText Integration
(1) streamText Streaming Architecture
graph LR
A[User Messages] --> B[Route Handler<br/>POST /api/chat]
B --> C[AI SDK streamText]
C --> D[LLM Provider<br/>OpenAI / Anthropic]
D -->|Flow Token| E[ReadableStream]
E --> F[Client Component<br/>useChat Hook]
F --> G[Typewriter Effect]
style B fill:#cce5ff
style C fill:#d4edda
style F fill:#fff3cd
| Component | Function | Installation |
|---|---|---|
ai Core Library |
streamText Functions |
npm install ai |
@ai-sdk/openai |
OpenAI Provider | npm install @ai-sdk/openai |
useChat |
Client Hook | Included in the ai package |
(2) Server-Side Streaming Routing
// app/api/chat/route.ts — AI Live Chat API
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o'),
system: 'You are TaskFlow\'s AI Assistant. Answer questions related to project management.',
messages
})
return result.toDataStreamResponse()
}
▶ Example: Typewriter effect on the client side
Output:
TypeScript code executed successfully.
// components/ChatBox.tsx
'use client'
import { useChat } from 'ai/react'
export default function ChatBox() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{ height: 400, overflowY: 'auto', border: '1px solid #ccc', padding: '1rem' }}>
{messages.map((m) => (
<div key={m.id} style={{
textAlign: m.role === 'user' ? 'right' : 'left',
marginBottom: '1rem'
}}>
<strong>{m.role === 'user' ? 'You' : 'AI'}:</strong>
<p>{m.content}</p>
</div>
))}
{isLoading && <p>AI Typing......</p>}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', marginTop: '1rem' }}>
<input
value={input}
onChange={handleInputChange}
placeholder="Enter your question..."
style={{ flex: 1, padding: '0.5rem' }}
/>
<button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem' }}>
Send
</button>
</form>
</div>
)
}
Output:
Renders a list by mapping over messages, displaying each m.
useChat The default is POST /api/chat. To specify a custom API endpoint, pass the api option: useChat({ api: '/api/ai/chat' }).
8. Complete Example: AI Analytics Dashboard + Streaming Data
// app/dashboard/page.tsx — Flow Dashboard + AI Analysis
import { Suspense } from 'react'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
// Frame Display Modules
function MetricSkeleton() {
return <div style={{ height: 100, background: '#f0f0f0', borderRadius: 8 }} />
}
function ChartSkeleton() {
return <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8 }} />
}
// Slow Data Component
async function TeamMetrics() {
const metrics = await new Promise<{ members: number; projects: number; tasks: number }>(
(resolve) => setTimeout(() => resolve({ members: 12, projects: 45, tasks: 230 }), 1500)
)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
<div>👥 {metrics.members} Members</div>
<div>📁 {metrics.projects} Project</div>
<div>✅ {metrics.tasks} Task</div>
</div>
)
}
async function ActivityChart() {
const data = await new Promise<number[]>((r) => setTimeout(() => r([30, 45, 78, 92, 55, 88, 120]), 2000))
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.5rem', height: 200 }}>
{data.map((v, i) => (
<div key={i} style={{ height: v, width: 40, background: '#4f46e5', borderRadius: '4px 4px 0 0' }} />
))}
</div>
)
}
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect('/login')
return (
<div>
<h1>TaskFlow Overview</h1>
<p>Welcome back,{session.user!.name}</p>
<Suspense fallback={<MetricSkeleton />}>
<TeamMetrics />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<ActivityChart />
</Suspense>
</div>
)
}
// app/api/chat/route.ts — AI Analysis Assistant
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o-mini'),
system: 'You are a project management AI Assistant. Based on the project data provided, provide an analysis and recommendations. Keep your answers brief.',
messages
})
return result.toDataStreamResponse()
}
// components/ChatPanel.tsx
'use client'
import { useChat } from 'ai/react'
export default function ChatPanel() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
return (
<div style={{ position: 'fixed', bottom: 0, right: 20, width: 380, border: '1px solid #ccc', borderRadius: '8px 8px 0 0' }}>
<div style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', borderRadius: '8px 8px 0 0' }}>
AI Analysis Assistant
</div>
<div style={{ height: 300, overflowY: 'auto', padding: '0.5rem' }}>
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: '0.5rem' }}>
<strong>{m.role === 'user' ? 'Me' : 'AI'}:</strong>
<p style={{ margin: 0 }}>{m.content}</p>
</div>
))}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', borderTop: '1px solid #eee' }}>
<input value={input} onChange={handleInputChange} placeholder="Ask a Question About the Project..." style={{ flex: 1, padding: '0.5rem', border: 'none' }} />
<button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', border: 'none' }}>Send</button>
</form>
</div>
)
}
❓ FAQ
loading.tsx is a file convention that automatically creates a Suspense boundary for the entire page, making implementation simple. <Suspense> components are used for fine-grained control within a page; they can wrap any number of independent data blocks for parallel streaming.use() better than await?use() can be used in client components, allowing the component itself to declare its dependency on a Promise, with the nearest Suspense boundary handling the loading state. It is suitable for scenarios where asynchronous operations need to be triggered on the front end (such as clicking “Load More”).streamText method and calling the OpenAI API directly?streamText automatically handles the SSE (Server-Sent Events) protocol, backpressure control, token counting, and error retries. Calling the OpenAI API directly requires manual handling of ReadableStream and the response format.<Suspense>. PPR pre-generates the static parts, and the dynamic parts are rendered on the fly—the two complement each other perfectly.📖 Summary
- Suspense divides the page into independent streaming blocks, preventing serial requests from blocking the entire page
loading.tsxThe file automatically creates a Suspense for the route page, with a fallback displaying a skeleton screen- Nested Suspense strategy: Outer skeleton (immediate) → Middle outline (~500 ms) → Inner details (~1200 ms)
- React 19
use()Hook consumes Promises in Client Components, used with Suspense - AI SDK
streamText+useChatfor streaming AI conversations with a typewriter-style effect - Combining stream rendering with PPR static shells: pre-rendering static parts + streaming dynamic parts
📝 Exercises
-
Basic Exercise (⭐): Implement three Suspense boundaries on a single page to load the user list, project statistics, and activity log, respectively, with each boundary having a different delay (500 ms / 1000 ms / 1500 ms).
-
Advanced Exercise (⭐⭐): Use the AI SDK’s
streamTextto create a translation assistant API route, and useuseChaton the client side to implement a typewriter effect that displays the translation results one character at a time. -
Challenge (⭐⭐⭐): Implement a multi-level nested dashboard page: The outer layer
loading.tsxdisplays the full-page skeleton view, with three nested layers of Suspense inside the page (statistics card → chart → detailed list). The outermost layer uses theuse()hook to load more data when clicked.



