Your Next.js app flies in development. Localhost is instant. You deploy, and suddenly real users are staring at spinners for 3+ seconds. What happened?
If you’re fetching data inside useEffect, you’ve likely built a data fetching waterfall without realizing it. Each component waits for the previous one to finish before it even starts loading its own data — stacking network round trips like dominoes falling in slow motion.
I’ve seen this pattern cripple dashboards, e-commerce pages, and internal tools across multiple teams. Let’s break down why it happens and how to fix it.
What Is a Data Fetching Waterfall?
A waterfall occurs when network requests run sequentially instead of in parallel. In React, this happens naturally when child components depend on data fetched by parent components.
Here’s a typical example:
// ❌ The waterfall pattern
function Dashboard() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data));
}, []);
return (
<div>
<UserProfile user={user} />
<Activities userId={user?.id} />
<Notifications userId={user?.id} />
</div>
);
}
function Activities({ userId }) {
const [data, setData] = useState([]);
useEffect(() => {
if (!userId) return;
fetch(`/api/activities?userId=${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]);
return <div>{/* ... */}</div>;
}The timeline looks like this:
| Time | What happens |
|---|---|
| 0ms | Page loads, Dashboard mounts, fetches /api/user |
| ~200ms | User data arrives, children re-render with userId |
| ~200ms | Activities and Notifications now start fetching |
| ~400ms | Child data finally arrives |
The children sat idle for 200ms doing nothing. That’s the waterfall.
Each waterfall level adds a full network round trip. On mobile networks with 100ms+ latency, a 3-level waterfall can easily push your LCP past the 2.5s “poor” threshold — tanking Core Web Vitals and SEO rankings.
Why useEffect Is the Root Cause
The issue isn’t useEffect itself — it’s using it for data fetching in a framework that offers far better alternatives.
1. Client-Only Execution
useEffect runs after the component renders in the browser. During SSR, it doesn’t execute at all. This means:
- The server sends empty shells or loading spinners
- The browser must download JS, parse it, render components, then start fetching
- Search engines see no meaningful content
2. Parent-Child Dependencies Create Chains
When a child’s fetch depends on the parent’s data, you get sequential chains:
- Parent mounts → fetches → resolves → re-renders
- Only then child mounts → fetches → resolves → re-renders
- Each level = another network round trip
3. No Request Deduplication
If three components all fetch /api/user, you get three separate requests. No caching, no sharing, just wasted bandwidth.
4. Bypasses React Suspense
Modern React’s Suspense model lets you coordinate loading states declaratively. useEffect opts you out of this entirely, forcing manual isLoading state management everywhere.
Four Modern Alternatives
Next.js gives you powerful built-in patterns to eliminate waterfalls. Here’s when to use each.
1. Server Components + Parallel Fetching
Best for: Initial page loads, SEO-critical content, data available at request time.
Server Components fetch data on the server before any HTML reaches the browser. Combined with Promise.all, all requests fire simultaneously.
// ✅ Server Component — no useEffect, no waterfall
async function Dashboard() {
const [user, activities, notifications] = await Promise.all([
fetch('https://api.example.com/user').then(r => r.json()),
fetch('https://api.example.com/activities').then(r => r.json()),
fetch('https://api.example.com/notifications').then(r => r.json()),
]);
return (
<div>
<UserProfile user={user} />
<Activities activities={activities} />
<Notifications notifications={notifications} />
</div>
);
}Why it works:
- All three requests fire in parallel on the server (low latency to your API)
- HTML arrives fully rendered — instant First Contentful Paint
- No client-side JavaScript needed for data fetching
- SEO-friendly by default
A team I worked with migrated a dashboard from useEffect waterfalls to parallel Server Components. LCP dropped from 4.2s to 1.1s — a 74% improvement that moved their Core Web Vitals from “Poor” to “Good.”
2. Suspense Boundaries for Progressive Loading
Best for: Pages with mixed fast/slow data sources where you want critical content visible immediately.
Not all data loads at the same speed. Suspense lets you stream fast content to the user while slower content loads in the background.
import { Suspense } from 'react';
async function UserProfile() {
const user = await fetch('https://api.example.com/user', {
cache: 'force-cache',
}).then(r => r.json());
return <div>{user.name}</div>;
}
async function Activities() {
// This API is slow — 800ms response time
const data = await fetch('https://api.example.com/activities', {
cache: 'no-store',
}).then(r => r.json());
return <div>{/* render activities */}</div>;
}
export default function Dashboard() {
return (
<div>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ActivitiesSkeleton />}>
<Activities />
</Suspense>
</div>
);
}Why it works:
- Fast content appears instantly, slow content streams in when ready
- Neither blocks the other — truly non-blocking
- Skeleton states give users immediate visual feedback
- HTML streams progressively via SSR
Don’t wrap every single component in its own Suspense boundary — this causes layout shifts. Group related content together. One Suspense for a list is better than one per list item.
3. ISR (Incremental Static Regeneration)
Best for: Content that updates periodically (hourly, daily) but doesn’t need real-time freshness.
Why fetch on every request when the data only changes once an hour? ISR pre-renders pages at build time and revalidates in the background.
// app/dashboard/page.tsx
async function Dashboard() {
const data = await fetch('https://api.example.com/dashboard', {
next: { revalidate: 3600 }, // Revalidate every hour
}).then(r => r.json());
return (
<div>
<UserProfile user={data.user} />
<Activities activities={data.activities} />
</div>
);
}
export default Dashboard;Why it works:
- Pages served from CDN edge — ~50ms load times
- Background revalidation keeps content fresh without blocking users
- Dramatically fewer origin server requests
- Perfect for dashboards with periodic metrics, blog listings, product catalogs
4. Next.js 16: cacheComponents + use cache
Best for: Apps that need fresh data by default, with selective caching for specific components or pages.
Next.js 16 introduces a fundamental shift in how caching works. With the cacheComponents flag, the default flips: nothing is cached unless you explicitly say so. Instead of opting out of caching with cache: 'no-store', you opt in with the use cache directive.
First, enable it in your config:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;Then use the use cache directive to mark what should be cached — at the page, component, or function level:
// This component fetches fresh data on every request (the new default)
async function LiveDashboard() {
const metrics = await fetch('https://api.example.com/metrics').then(r => r.json());
return <div>{/* always fresh */}</div>;
}
// This component is cached — add 'use cache' to opt in
async function StaticSidebar() {
'use cache';
const links = await fetch('https://api.example.com/nav').then(r => r.json());
return <nav>{/* cached, fast */}</nav>;
}
// Fine-grained control with cacheLife and cacheTag
async function ProductCatalog() {
'use cache';
cacheLife('hours'); // Revalidate every hour
cacheTag('products'); // Tag for on-demand revalidation
const products = await fetch('https://api.example.com/products').then(r => r.json());
return <div>{/* cached with hourly refresh */}</div>;
}Why it works:
- Dynamic by default — no more accidentally serving stale data
- Granular opt-in — cache at the page, component, or function level
- Simpler mental model — you explicitly choose what to cache instead of guessing what’s already cached
- Preserved navigation state — Next.js uses React’s
<Activity>component to keep UI state (form inputs, scroll position) intact when navigating between routes
This is a significant mental model shift. Previous Next.js versions cached aggressively by default, which led to confusing stale data bugs. With cacheComponents, the philosophy becomes: fresh by default, cached by choice. If you’re starting a new Next.js 16 project, this is the recommended approach.
Quick Decision Guide
| Scenario | Use This |
|---|---|
| SEO-critical, data at request time | Server Components + Promise.all |
| Mixed fast/slow data sources | Suspense Boundaries |
| Data changes hourly/daily | ISR with revalidate |
| Fresh by default, selective caching | cacheComponents + use cache |
| User interactions (clicks, forms) | Client Components with 'use client' |
Migration Playbook
If you have an existing codebase full of useEffect fetches, here’s a practical path forward:
Step 1: Audit. Search for useEffect + fetch patterns. Map the dependency chains between parent and child components.
Step 2: Start at the top. Convert page-level components first. Remove 'use client', make the function async, and await your fetches directly.
// Before
'use client';
export default function Page() {
const [data, setData] = useState(null);
useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData) }, []);
return <div>{/* ... */}</div>;
}
// After
export default async function Page() {
const data = await fetch('/api/data').then(r => r.json());
return <div>{/* ... */}</div>;
}Step 3: Parallelize. Replace sequential fetches with Promise.all().
Step 4: Add Suspense. Wrap slower sections in <Suspense> with skeleton fallbacks.
Step 5: Keep 'use client' only where needed. Components with event handlers, browser APIs, or hooks like useState still need it. Everything else should be a Server Component by default.
Common Mistakes to Avoid
Importing Server Components into Client Components:
// ❌ Won't work as expected
'use client';
import ServerComponent from './ServerComponent';
// ✅ Pass as children instead
'use client';
export function ClientWrapper({ children }) {
return <div onClick={handleClick}>{children}</div>;
}
// In a Server Component parent:
<ClientWrapper>
<ServerComponent />
</ClientWrapper>Using cache: 'no-store' for static data:
// ❌ Refetches on every request
await fetch('/api/config', { cache: 'no-store' });
// ✅ Cache static data
await fetch('/api/config', { cache: 'force-cache' });The Bottom Line
Data fetching waterfalls from useEffect are one of the most common — and most fixable — performance problems in Next.js apps. The framework already gives you the tools:
- Server Components eliminate client-side fetching entirely
- Suspense lets fast content load without waiting for slow content
- ISR serves pre-built pages from the edge in milliseconds
cacheComponents+use cachegives you fresh data by default with granular opt-in caching
Stop the waterfall. Your users (and your Lighthouse scores) will thank you.
Ứng dụng Next.js của bạn chạy cực nhanh trên localhost. Bạn deploy lên production, và bỗng nhiên người dùng phải nhìn spinner quay 3 giây trở lên. Chuyện gì đã xảy ra?
Nếu bạn đang fetch data bên trong useEffect, rất có thể bạn đã vô tình tạo ra một data fetching waterfall (hiệu ứng thác nước). Mỗi component phải đợi component trước hoàn thành rồi mới bắt đầu tải dữ liệu của riêng nó — xếp chồng các request mạng như những quân domino đổ từ từ.
Tôi đã thấy pattern này làm chậm dashboard, trang e-commerce và các công cụ nội bộ ở nhiều team khác nhau. Hãy cùng phân tích tại sao nó xảy ra và cách khắc phục.
Data Fetching Waterfall Là Gì?
Waterfall xảy ra khi các request mạng chạy tuần tự thay vì song song. Trong React, điều này xảy ra tự nhiên khi component con phụ thuộc vào dữ liệu được fetch bởi component cha.
Đây là một ví dụ điển hình:
// ❌ Pattern tạo waterfall
function Dashboard() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data));
}, []);
return (
<div>
<UserProfile user={user} />
<Activities userId={user?.id} />
<Notifications userId={user?.id} />
</div>
);
}
function Activities({ userId }) {
const [data, setData] = useState([]);
useEffect(() => {
if (!userId) return;
fetch(`/api/activities?userId=${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]);
return <div>{/* ... */}</div>;
}Timeline diễn ra như sau:
| Thời gian | Điều xảy ra |
|---|---|
| 0ms | Trang tải, Dashboard mount, fetch /api/user |
| ~200ms | Dữ liệu user trả về, component con re-render với userId |
| ~200ms | Activities và Notifications bây giờ mới bắt đầu fetch |
| ~400ms | Dữ liệu con cuối cùng cũng đến |
Component con đã ngồi chờ 200ms mà không làm gì. Đó chính là waterfall.
Mỗi tầng waterfall thêm một vòng request mạng đầy đủ. Trên mạng di động với độ trễ 100ms+, waterfall 3 tầng có thể dễ dàng đẩy LCP vượt ngưỡng 2.5s “kém” — làm tụt Core Web Vitals và thứ hạng SEO.
Tại Sao useEffect Là Nguyên Nhân Gốc
Vấn đề không phải bản thân useEffect — mà là dùng nó để fetch data trong một framework đã cung cấp các giải pháp tốt hơn nhiều.
1. Chỉ Chạy Trên Client
useEffect chạy sau khi component render trên trình duyệt. Trong quá trình SSR, nó không chạy. Điều này có nghĩa:
- Server gửi về các shell rỗng hoặc spinner loading
- Trình duyệt phải tải JS, parse, render component, rồi mới bắt đầu fetch
- Công cụ tìm kiếm không thấy nội dung có ý nghĩa
2. Phụ Thuộc Cha-Con Tạo Chuỗi Tuần Tự
Khi fetch của component con phụ thuộc vào data của cha, bạn có chuỗi tuần tự:
- Cha mount → fetch → resolve → re-render
- Chỉ sau đó con mới mount → fetch → resolve → re-render
- Mỗi tầng = thêm một vòng request mạng
3. Không Có Request Deduplication
Nếu ba component cùng fetch /api/user, bạn có ba request riêng biệt. Không cache, không chia sẻ, chỉ lãng phí bandwidth.
4. Bỏ Qua React Suspense
Mô hình Suspense hiện đại của React cho phép bạn điều phối loading state một cách khai báo. useEffect khiến bạn bỏ qua hoàn toàn điều này, buộc phải quản lý isLoading thủ công ở khắp nơi.
Bốn Giải Pháp Hiện Đại
Next.js cung cấp các pattern built-in mạnh mẽ để loại bỏ waterfall. Đây là khi nào nên dùng từng cái.
1. Server Components + Fetch Song Song
Phù hợp nhất cho: Tải trang ban đầu, nội dung quan trọng cho SEO, dữ liệu có sẵn tại thời điểm request.
Server Components fetch data trên server trước khi bất kỳ HTML nào đến trình duyệt. Kết hợp với Promise.all, tất cả request được gửi đồng thời.
// ✅ Server Component — không useEffect, không waterfall
async function Dashboard() {
const [user, activities, notifications] = await Promise.all([
fetch('https://api.example.com/user').then(r => r.json()),
fetch('https://api.example.com/activities').then(r => r.json()),
fetch('https://api.example.com/notifications').then(r => r.json()),
]);
return (
<div>
<UserProfile user={user} />
<Activities activities={activities} />
<Notifications notifications={notifications} />
</div>
);
}Tại sao hiệu quả:
- Cả ba request gửi song song trên server (độ trễ thấp đến API)
- HTML đến đã render đầy đủ — First Contentful Paint tức thì
- Không cần JavaScript phía client cho data fetching
- Thân thiện SEO mặc định
Một team tôi làm việc cùng đã migrate dashboard từ useEffect waterfall sang Server Components song song. LCP giảm từ 4.2s xuống 1.1s — cải thiện 74%, đưa Core Web Vitals từ “Kém” lên “Tốt.”
2. Suspense Boundaries Cho Loading Dần Dần
Phù hợp nhất cho: Trang có nguồn dữ liệu nhanh/chậm khác nhau, muốn nội dung quan trọng hiển thị ngay.
Không phải tất cả dữ liệu đều tải cùng tốc độ. Suspense cho phép bạn stream nội dung nhanh đến người dùng trong khi nội dung chậm hơn tải ở background.
import { Suspense } from 'react';
async function UserProfile() {
const user = await fetch('https://api.example.com/user', {
cache: 'force-cache',
}).then(r => r.json());
return <div>{user.name}</div>;
}
async function Activities() {
// API này chậm — 800ms response time
const data = await fetch('https://api.example.com/activities', {
cache: 'no-store',
}).then(r => r.json());
return <div>{/* render activities */}</div>;
}
export default function Dashboard() {
return (
<div>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ActivitiesSkeleton />}>
<Activities />
</Suspense>
</div>
);
}Tại sao hiệu quả:
- Nội dung nhanh xuất hiện ngay, nội dung chậm stream vào khi sẵn sàng
- Không cái nào block cái nào — thực sự non-blocking
- Skeleton state cho người dùng phản hồi trực quan ngay lập tức
- HTML stream dần qua SSR
Đừng wrap mỗi component đơn lẻ trong Suspense boundary riêng — điều này gây layout shift. Nhóm nội dung liên quan lại. Một Suspense cho danh sách tốt hơn một Suspense cho mỗi item.
3. ISR (Incremental Static Regeneration)
Phù hợp nhất cho: Nội dung cập nhật định kỳ (mỗi giờ, hàng ngày) nhưng không cần real-time.
Tại sao phải fetch mỗi request khi dữ liệu chỉ thay đổi mỗi giờ? ISR pre-render trang lúc build và revalidate ở background.
// app/dashboard/page.tsx
async function Dashboard() {
const data = await fetch('https://api.example.com/dashboard', {
next: { revalidate: 3600 }, // Revalidate mỗi giờ
}).then(r => r.json());
return (
<div>
<UserProfile user={data.user} />
<Activities activities={data.activities} />
</div>
);
}
export default Dashboard;Tại sao hiệu quả:
- Trang được phục vụ từ CDN edge — thời gian tải ~50ms
- Background revalidation giữ nội dung mới mà không block người dùng
- Giảm đáng kể request đến origin server
- Hoàn hảo cho dashboard với metric định kỳ, danh sách blog, catalog sản phẩm
4. Next.js 16: cacheComponents + use cache
Phù hợp nhất cho: Ứng dụng cần dữ liệu mới mặc định, với caching có chọn lọc cho từng component hoặc page cụ thể.
Next.js 16 giới thiệu một thay đổi căn bản trong cách caching hoạt động. Với flag cacheComponents, mặc định bị đảo ngược: không gì được cache trừ khi bạn chỉ định rõ ràng. Thay vì opt out khỏi cache với cache: 'no-store', bạn opt in bằng directive use cache.
Đầu tiên, bật trong config:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;Sau đó dùng directive use cache để đánh dấu những gì nên cache — ở cấp page, component, hoặc function:
// Component này fetch dữ liệu mới mỗi request (mặc định mới)
async function LiveDashboard() {
const metrics = await fetch('https://api.example.com/metrics').then(r => r.json());
return <div>{/* luôn mới */}</div>;
}
// Component này được cache — thêm 'use cache' để opt in
async function StaticSidebar() {
'use cache';
const links = await fetch('https://api.example.com/nav').then(r => r.json());
return <nav>{/* đã cache, nhanh */}</nav>;
}
// Kiểm soát chi tiết với cacheLife và cacheTag
async function ProductCatalog() {
'use cache';
cacheLife('hours'); // Revalidate mỗi giờ
cacheTag('products'); // Tag để revalidation theo yêu cầu
const products = await fetch('https://api.example.com/products').then(r => r.json());
return <div>{/* cache với refresh mỗi giờ */}</div>;
}Tại sao hiệu quả:
- Dynamic mặc định — không còn vô tình phục vụ dữ liệu cũ
- Opt-in chi tiết — cache ở cấp page, component, hoặc function
- Mental model đơn giản hơn — bạn chọn rõ ràng cái gì cần cache thay vì đoán cái gì đã được cache
- Bảo toàn state khi điều hướng — Next.js dùng component
<Activity>của React để giữ UI state (input form, vị trí scroll) nguyên vẹn khi chuyển trang
Đây là thay đổi mental model quan trọng. Các phiên bản Next.js trước cache mạnh mẽ mặc định, dẫn đến bug dữ liệu cũ khó hiểu. Với cacheComponents, triết lý trở thành: mới mặc định, cache theo lựa chọn. Nếu bạn bắt đầu dự án Next.js 16 mới, đây là cách tiếp cận được khuyến nghị.
Hướng Dẫn Chọn Nhanh
| Tình huống | Dùng cái này |
|---|---|
| Quan trọng cho SEO, data tại thời điểm request | Server Components + Promise.all |
| Nguồn dữ liệu nhanh/chậm khác nhau | Suspense Boundaries |
| Dữ liệu thay đổi mỗi giờ/ngày | ISR với revalidate |
| Mới mặc định, cache có chọn lọc | cacheComponents + use cache |
| Tương tác người dùng (click, form) | Client Components với 'use client' |
Kế Hoạch Migration
Nếu bạn có codebase đầy useEffect fetch, đây là lộ trình thực tế:
Bước 1: Kiểm tra. Tìm các pattern useEffect + fetch. Vẽ chuỗi phụ thuộc giữa component cha và con.
Bước 2: Bắt đầu từ trên. Chuyển đổi component cấp page trước. Xóa 'use client', thêm async cho function, và await fetch trực tiếp.
// Trước
'use client';
export default function Page() {
const [data, setData] = useState(null);
useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData) }, []);
return <div>{/* ... */}</div>;
}
// Sau
export default async function Page() {
const data = await fetch('/api/data').then(r => r.json());
return <div>{/* ... */}</div>;
}Bước 3: Song song hóa. Thay thế fetch tuần tự bằng Promise.all().
Bước 4: Thêm Suspense. Wrap các phần chậm hơn trong <Suspense> với skeleton fallback.
Bước 5: Giữ 'use client' chỉ khi cần. Component có event handler, browser API, hoặc hook như useState vẫn cần nó. Mọi thứ khác nên là Server Component mặc định.
Lỗi Thường Gặp Cần Tránh
Import Server Component vào Client Component:
// ❌ Không hoạt động như mong đợi
'use client';
import ServerComponent from './ServerComponent';
// ✅ Truyền qua children thay thế
'use client';
export function ClientWrapper({ children }) {
return <div onClick={handleClick}>{children}</div>;
}
// Trong Server Component cha:
<ClientWrapper>
<ServerComponent />
</ClientWrapper>Dùng cache: 'no-store' cho dữ liệu tĩnh:
// ❌ Fetch lại mỗi request
await fetch('/api/config', { cache: 'no-store' });
// ✅ Cache dữ liệu tĩnh
await fetch('/api/config', { cache: 'force-cache' });Kết Luận
Data fetching waterfall từ useEffect là một trong những vấn đề hiệu năng phổ biến nhất — và dễ sửa nhất — trong ứng dụng Next.js. Framework đã cho bạn đầy đủ công cụ:
- Server Components loại bỏ hoàn toàn client-side fetching
- Suspense cho nội dung nhanh tải mà không cần đợi nội dung chậm
- ISR phục vụ trang pre-built từ edge trong vài mili giây
cacheComponents+use cachecho dữ liệu mới mặc định với caching opt-in chi tiết
Hãy dừng waterfall. Người dùng (và điểm Lighthouse) của bạn sẽ cảm ơn bạn.