Waterfalls creep in when data dependencies hide inside components.
A component fetches, renders a child, the child fetches, renders a grandchild, the grandchild fetches. The page takes 3x longer.
Break the waterfalls by fetching in parallel and at the top level.
Fetch in Parallel
Move all fetches to the top level. Use Promise.all for sibling fetches.
// ❌ Waterfall
export default async function Page({ params }) {
const user = await fetchUser(params.id); // 100ms
const posts = await fetchPosts(user.id); // 100ms, waits for user
const comments = await fetchComments(posts[0]?.id); // 100ms, waits for posts
return <div>...</div>; // 300ms total
}
// ✅ Parallel
export default async function Page({ params }) {
const user = await fetchUser(params.id); // 100ms
const [posts, recommendations] = await Promise.all([
fetchPosts(user.id), // 100ms, parallel with recommendations
fetchRecommendations(user.id) // 100ms
]);
return <div>...</div>; // 200ms total
}
If comments depend on the first post, fetch it separately:
export default async function Page({ params }) {
const user = await fetchUser(params.id);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0]?.id); // Fine: needed for initial render
return <div>{comments}</div>;
}
// But lazy-load secondary comments
export function CommentList({ postId }) {
const { data } = useQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId)
});
return <div>{data?.map(c => <Comment key={c.id} {...c} />)}</div>;
}
Move Work Server-Side
Server components for reads, server actions for writes.
Server Components for Cacheable Reads
// app/products/page.tsx
import { cache } from 'react';
const getCachedProducts = cache(() =>
fetch('https://api.example.com/products', {
next: { revalidate: 60 } // Cache for 1 minute
}).then(r => r.json())
);
export default async function ProductList() {
const products = await getCachedProducts();
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
The cache() function dedupes identical requests within a single render:
// If both ProductList and ProductSearch call getCachedProducts(),
// the fetch happens once.
const products = await getCachedProducts(); // 100ms
const products2 = await getCachedProducts(); // 0ms (cached)
Request Deduplication
Use a deduping cache to avoid identical requests:
const requestCache = new Map();
async function fetchWithCache(url: string) {
if (requestCache.has(url)) {
return requestCache.get(url);
}
const promise = fetch(url).then((r) => r.json());
requestCache.set(url, promise);
return promise;
}
// Two components fetch the same data
const users1 = await fetchWithCache("/api/users"); // 100ms, cached
const users2 = await fetchWithCache("/api/users"); // Same promise, 0ms
Shape Payloads
Ask for View Models, Not Raw Tables
Instead of returning all user fields, return only what the view needs:
// ❌ Over-fetching
export async function getUser(id: string) {
return db.user.findUnique({ where: { id } });
// Returns: { id, name, email, passwordHash, lastLogin, ... 30 fields }
// Client only needs: { id, name, email }
}
// ✅ View model
export async function getUserForDisplay(id: string) {
return db.user.findUnique({
where: { id },
select: { id: true, name: true, email: true },
});
}
Paginate Aggressively
Do not return 1000 items. Return 20 and implement cursor-based pagination:
export async function getProducts(limit = 20, cursor?: string) {
const products = await db.product.findMany({
take: limit + 1, // Fetch one extra to determine if there are more
...(cursor && { skip: 1, cursor: { id: cursor } }),
orderBy: { id: "asc" },
});
const hasMore = products.length > limit;
return {
products: products.slice(0, limit),
nextCursor: hasMore ? products[limit].id : null,
};
}
Client requests the next page:
function ProductList() {
const [cursor, setCursor] = useState<string | null>(null);
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = null }) => fetch(`/api/products?cursor=${pageParam}`).then(r => r.json()),
getNextPageParam: (lastPage) => lastPage.nextCursor
});
return (
<>
{data?.pages.flatMap(page => page.products).map(p => <Product key={p.id} {...p} />)}
{data?.pages.at(-1)?.nextCursor && (
<button onClick={() => fetchNextPage()}>Load More</button>
)}
</>
);
}
Precompute Expensive Aggregates
Do not compute sums, counts, or groupings on the client. Do them on the server and store the result:
// ❌ Client computes
const users = await fetchAllUsers(); // 1000 items
const activeCount = users.filter((u) => u.isActive).length;
// ✅ Server precomputes
const { activeCount } = await fetchUserStats();
Measure Waterfalls
Use server-side timing or traces to identify waterfalls:
const start = Date.now();
const user = await fetchUser(params.id);
console.log(`Fetched user in ${Date.now() - start}ms`);
const posts = await fetchPosts(user.id);
console.log(`Fetched posts in ${Date.now() - start}ms`);
const comments = await fetchComments(posts[0]?.id);
console.log(`Fetched comments in ${Date.now() - start}ms`);
// Output:
// Fetched user in 100ms
// Fetched posts in 200ms (100 + 100)
// Fetched comments in 300ms (100 + 100 + 100) ← WATERFALL
Conclusion
- Fetch data at the top level (page/layout).
- Use
Promise.allfor parallel fetches. - Use server components to dedupe requests.
- Shape payloads to match view needs.
- Paginate large datasets.
- Measure to catch waterfalls early.
Fewer round trips and better payloads = faster pages.