Data Fetching: fetch & RSC
In RSC,
fetchis no longer just a browser fetch—it extends the cache layer, allowing you to control the data lifecycle in a declarative way.
1. What You'll Learn
- Automatic caching behavior of
fetch(url, options)in the Server Component - Three cache modes:
force-cache,no-store,revalidate next: { tags }andrevalidateTag()are re-authenticated on demand- Parallel Data Retrieval
Promise.all()to Avoid a Waterfall Effect - Identification and Optimization of Serial Waterfall Flows
2. A True Story of a Full-Stack Developer
(1) Pain Point: The dashboard takes 8 seconds to load
Bob is the Technical Lead of the TaskFlow team. The Dashboard page needs to load five data sources: user statistics, total number of projects, recent tasks, activity logs, and system notifications. The initial code used five serial await fetch(...) calls, each waiting for the previous one to complete—resulting in a total time of 2.1s + 1.8s + 1.5s + 0.9s + 1.7s = 8 seconds. Users complained that the page took "too long to load." To make matters worse, the API was re-queried with every refresh, causing database load to spike to 5,000 QPS.
(2) The Next.js fetch Solution
Use
Promise.all()for parallel requests +next: { revalidate: 60 }for a 60-second cache.
// app/dashboard/page.tsx
export default async function DashboardPage() {
const [users, projects, tasks, logs, notifs] = await Promise.all([
fetch('https://api.example.com/stats/users', { next: { revalidate: 60 } }),
fetch('https://api.example.com/stats/projects', { next: { revalidate: 60 } }),
fetch('https://api.example.com/stats/tasks', { next: { revalidate: 30 } }),
fetch('https://api.example.com/activity/logs', { cache: 'no-store' }),
fetch('https://api.example.com/notifications', { next: { revalidate: 10 } }),
]).then(responses => Promise.all(responses.map(r => r.json())))
return <DashboardView {...{ users, projects, tasks, logs, notifs }} />
}
(3) Revenue
| Dimension | Before Optimization | After Optimization |
|---|---|---|
| Page load time | 8 seconds (serial) | 2.1 seconds (parallel) |
| Database QPS | 5,000 | 83 (60-second cache) |
| User Complaints | 12 per day | 0 |
| Number of lines of code | 35 lines (5 separate fetches) | 10 lines |
3. The Three Caching Modes of fetch
Next.js 16 extends the Web fetch API by adding three caching modes. All fetch in RSC use force-cache (auto-caching) by default, unless another mode is explicitly specified.
graph LR
A[RSC fetch] --> B{Caching Mode}
B --> C[force-cache<br/>Default value]
B --> D[no-store<br/>Every time the page is refreshed]
B --> E[revalidate:N<br/>Time Window]
C --> F[Data Cache<br/>Persistent Storage]
D --> G[Real-time Data<br/>Do not cache]
E --> H[N Cached within seconds<br/>Re-obtain after expiration]
style C fill:#d4edda
style D fill:#f8d7da
style E fill:#fff3cd
| Pattern | Syntax | Behavior | Use Cases |
|---|---|---|---|
force-cache (default) |
fetch(url) or fetch(url, { cache: 'force-cache' }) |
Retrieved only during build or on the first request; results are cached permanently | Data that rarely changes (documents, static configuration) |
no-store |
fetch(url, { cache: 'no-store' }) |
Retrieve data anew with every request; no caching | Real-time data (user information, inventory) |
revalidate:N |
fetch(url, { next: { revalidate: 60 } }) |
Cached for 60 seconds; the background process triggers an update upon expiration | Semi-real-time data (news, leaderboards) |
(1) force-cache Default Behavior
If no options are passed, Next.js automatically caches fetch results—requests with the same URL and options are made only once during the build process.
// app/products/page.tsx — force-cache Default
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products').then(r => r.json())
// Retrieve once during build time,Use the cache afterward
return <ProductList data={products} />
}
(2) no-store Dynamic Data
// app/profile/page.tsx — Retrieve the latest data with every request
export default async function ProfilePage() {
const user = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` }
}).then(r => r.json())
return <ProfileView user={user} />
}
(3) revalidate Time Window
// app/blog/[slug]/page.tsx — ISR Style Cache
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 } // 1 Use the cache within an hour
}).then(r => r.json())
return <article><h1>{post.title}</h1><div>{post.content}</div></article>
}
▶ Example: Comparison of Three Caching Modes (Difficulty: ⭐)
Output:
Fetches data and renders the result.
// app/cache-demo/page.tsx
export default async function CacheDemoPage() {
const staticData = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'force-cache'
}).then(r => r.json())
const liveData = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'no-store'
}).then(r => r.json())
return (
<div>
<p>Static (force-cache): {staticData.datetime}</p>
<p>Live (no-store): {liveData.datetime}</p>
</div>
)
}
Output:
Static (force-cache): 2026-07-06T10:00:00.000Z ← Always the Same
Live (no-store): 2026-07-06T10:00:05.123Z ← It changes every time I refresh
Output:
Browser renders two timestamps:
Static (force-cache): 2026-07-06T10:00:00.000Z ← Always the same (cached at build time)
Live (no-store): 2026-07-06T10:00:05.123Z ← Changes on every refresh
4. On-Demand Revalidation: tags and revalidateTag
next: { tags: [...] } Tag the fetch request, then use revalidateTag(tag) to refresh the cache as needed in the Server Action or Route Handler.
sequenceDiagram
participant A as Server Action
participant Cache as Data Cache
participant DB as Database
A->>DB: Write New Data(Create a Task)
A->>Cache: revalidateTag('tasks')
Cache->>Cache: Clear tags All caches that match
Note over Cache: Next time fetch Retrieve Again
| API | Purpose | Where to Call |
|---|---|---|
next: { tags: ['tasks', 'projects'] } |
Tagging "fetch" | fetch() Options |
revalidateTag('tasks') |
Clear all related cache by tag | Server Action / Route Handler |
revalidatePath('/dashboard') |
Clear cache by path | Server Action / Route Handler |
▶ Example: Using tags and revalidateTag (Difficulty: ⭐⭐)
// app/tasks/data.ts — Data Retrieval Functions
export async function getTasks() {
return fetch('https://api.example.com/tasks', {
next: { tags: ['tasks'] }
}).then(r => r.json())
}
// app/tasks/actions.ts — Server Action Refresh the cache after writing
'use server'
import { revalidateTag } from 'next/cache'
export async function createTask(formData: FormData) {
const title = formData.get('title') as string
await fetch('https://api.example.com/tasks', {
method: 'POST',
body: JSON.stringify({ title, status: 'todo' })
})
revalidateTag('tasks') // Clear tasks All cached entries for this tag
}
Output:
Fetches data and renders the result.
▶ Example: Using revalidatePath to clear the entire page (Difficulty: ⭐⭐)
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function publishArticle() {
await db.article.update({ where: { id: 1 }, data: { published: true } })
revalidatePath('/blog') // Refresh /blog Page
revalidatePath('/blog/[slug]') // Refresh all article details
}
Output:
Renders the publishArticle component UI.
5. Parallel Data Retrieval and Avoiding Waterfall Effects
The waterfall pattern is the number one performance killer—each await waits in sequence for the previous one to complete. Using Promise.all() allows all requests to be initiated simultaneously.
graph LR
subgraph "Waterfall Pattern (Slow)"
A1[fetch A] --> A2[fetch B] --> A3[fetch C]
A1 -.- t1[2s]
A2 -.- t2[+2s = 4s]
A3 -.- t3[+2s = 6s]
end
subgraph "Parallel (Fast)"
B1[fetch A] -.- u1[2s]
C1[fetch B] -.- u2[2s]
D1[fetch C] -.- u3[2s]
B1 & C1 & D1 --> M[Promise.all<br/>Total Time ~2s]
end
| Pattern | Implementation | Total Time (2 seconds each) | Applicable Scenarios |
|---|---|---|---|
| Serial Waterfall | await A; await B; await C |
~6s | Dependent requests |
| Parallel Requests | Promise.all([A, B, C]) |
~2s | Independent, unrelated requests |
| Phased parallel processing | const a = await A; const [b, c] = await Promise.all([B(a.id), C]) |
~4s | Partially dependent requests |
▶ Example: Serial Waterfall Pattern Recognition (Difficulty: ⭐)
// app/waterfall/page.tsx — ❌ Serial Waterfall Chart
export default async function WaterfallPage() {
const user = await fetch('https://api.example.com/user').then(r => r.json()) // 1s
const tasks = await fetch(`https://api.example.com/tasks?userId=${user.id}`).then(r => r.json()) // Wait until that's done + 2s = 3s
const details = await Promise.all(tasks.map(t =>
fetch(`https://api.example.com/tasks/${t.id}/details`).then(r => r.json()) // Wait until that's done + 2s = 5s
))
return <div>Total: ~5s</div>
}
Output:
Fetches data and renders a list of items.
Visible text: Total: ~5s
▶ Example: Parallel Optimization (Difficulty: ⭐⭐)
Output:
The page renders as described above, with the UI updating based on the described behavior.
// app/no-waterfall/page.tsx — ✅ Parallel Optimization
export default async function NoWaterfallPage() {
// Stage 1:Concurrently Retrieve Users and Initialize Data
const [user, initialData] = await Promise.all([
fetch('https://api.example.com/user', { next: { revalidate: 10 } }).then(r => r.json()),
fetch('https://api.example.com/initial', { cache: 'no-store' }).then(r => r.json()),
])
// Stage 2:Dependency user.id Request(There are still small waterfalls,But it's already the best)
const tasks = await fetch(`https://api.example.com/tasks?userId=${user.id}`).then(r => r.json())
return <div>Total: ~2s (1s + 1s parallel, then 1s)</div>
}
Output:
Fetches data and renders the result.
Visible text: Total: ~2s (1s + 1s parallel, then 1s)
▶ Example: Suspense Lazy Loading (Difficulty: ⭐⭐⭐)
Output:
The page renders as described above, with the UI updating based on the described behavior.
// app/suspense-demo/page.tsx — Each separate area uses Suspense Package
import { Suspense } from 'react'
export default function SuspenseDemoPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading profile...</div>}>
<ProfileSection />
</Suspense>
<Suspense fallback={<div>Loading tasks...</div>}>
<TaskSection />
</Suspense>
</div>
)
}
async function ProfileSection() {
const user = await fetch('https://api.example.com/user', { cache: 'no-store' }).then(r => r.json())
return <div>Welcome, {user.name}</div>
}
async function TaskSection() {
const tasks = await fetch('https://api.example.com/tasks', { next: { revalidate: 30 } }).then(r => r.json())
return <ul>{tasks.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
}
Output:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: Loading profile...
Visible text: Dashboard | Loading profile... | }> | Loading tasks...
6. Complete Example: Optimized Dashboard
// app/dashboard-optimized/page.tsx
import { Suspense } from 'react'
import { revalidateTag } from 'next/cache'
// ======== Data Functions ========
const API = 'https://jsonplaceholder.typicode.com'
async function getData<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API}${endpoint}`, {
...options,
next: { tags: [endpoint.split('/')[1] ?? 'default'], ...(options as any)?.next },
})
if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`)
return res.json()
}
// ======== Retrieve all data in parallel ========
export default function DashboardOptimizedPage() {
return (
<div>
<h1>Optimized Dashboard</h1>
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 1fr' }}>
<Suspense fallback={<Skeleton label="Users" />}>
<DataCard title="Users" endpoint="/users" />
</Suspense>
<Suspense fallback={<Skeleton label="Posts" />}>
<DataCard title="Posts" endpoint="/posts" revalidate={120} />
</Suspense>
<Suspense fallback={<Skeleton label="Comments" />}>
<DataCard title="Comments" endpoint="/comments" />
</Suspense>
<Suspense fallback={<Skeleton label="Todos" />}>
<DataCard title="Todos" endpoint="/todos" revalidate={30} />
</Suspense>
</div>
</div>
)
}
async function DataCard({ title, endpoint, revalidate }: {
title: string
endpoint: string
revalidate?: number
}) {
const data = await getData<any[]>(endpoint, revalidate
? { next: { revalidate } }
: { cache: 'no-store' }
)
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>{title} <span style={{ fontSize: 14, color: '#666' }}>({data.length})</span></h2>
<ul>{data.slice(0, 5).map((item: any) => (
<li key={item.id}>{item.title ?? item.name ?? item.email}</li>
))}</ul>
</div>
)
}
function Skeleton({ label }: { label: string }) {
return <div style={{ border: '1px solid #eee', borderRadius: 8, padding: 16, opacity: 0.5 }}>
Loading {label}...
</div>
}
// app/dashboard-optimized/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function refreshSection(tag: string) {
revalidateTag(tag)
return { success: true }
}
❓ FAQ
npm run dev), force-cache is still fetched with every request (to facilitate debugging). Caching only takes effect in production mode (next start or after the build). This is a design decision in Next.js—to always fetch the latest data during the development phase.revalidateTag and revalidatePath?revalidateTag clears the cache by tag (for the same data across different pages), while revalidatePath clears the cache by path (down to the page or route pattern). The former is suitable for fine-grained data-layer control, while the latter is suitable for page-level refreshes. We recommend using revalidateTag whenever possible.cache or next.revalidate settings will be treated as different cache entries.Promise.all handle a failed request?Promise.all is an "all-or-nothing" operation—if any single request fails, the entire promise is rejected. If you need to handle errors, wrap each fetch call in Promise.allSettled or a try-catch block. A common pattern is const results = await Promise.all(urls.map(u => fetch(u).catch(() => null))).fetch handled in RSC?fetch does not have a built-in timeout. You can wrap it in an AbortController: const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 5000); fetch(url, { signal: ctrl.signal }). It is recommended to encapsulate a unified fetch client at the app level.fetch, such as automatic caching, tags, and revalidation. If you use axios, you’ll need to implement caching logic manually or wrap a fetch-compatible layer around axios. We recommend using the native fetch method whenever possible.📖 Summary
- The
fetchmethod in RSC usesforce-cacheby default, and identical URLs are automatically cached cache: 'no-store'Disable caching; suitable for real-time datanext: { revalidate: N }Implementing a time window cache (similar to ISR)next: { tags: [...] }+revalidateTag()to implement on-demand cache refreshPromise.all()Parallel requests prevent the "waterfall effect" and can reduce loading time by 60–80%- Suspense uses boundary splitting to enable streaming loading, so there's no need to wait for all data to be ready
revalidatePath()Clear page-level cache by path
📝 Exercises
-
Basic Exercise (⭐): Create a
app/time-demo/page.tsx, usecache: 'no-store'andcache: 'force-cache'to make separate requests to the World Time API, compare the difference between the two timestamps, and verify the caching behavior. -
Advanced Problem (⭐⭐): Build a
app/parallel-demo/page.tsxthat usesPromise.allto fetch/users,/posts, and/comments(using the JSONPlaceholder API), and render each data point within a separate<Suspense>bounding box to demonstrate a streaming loading effect. -
Challenge (⭐⭐⭐): Create a task list page that supports CRUD operations:
app/tasks/page.tsx(display the task list, using tags for caching) andapp/tasks/actions.ts(callrevalidateTag('tasks')to refresh the list after adding or deleting a task). Implement an optimistic update to ensure the list is refreshed immediately after a write operation.



