Avoiding Server Component Waterfall Fetching with React 19 cache()
01 Jun 2026
The cache() API is a native feature in React 19. In this post, we will explore how it works in the Next.js App Router, and see how it can be used to reduce data coupling, preload data, and eliminate waterfall fetching when using React Server Components.
The React 19 Cache API
The React 19 cache() function allows you to memoize the result of a data fetch or computation. It is designed specifically for React Server Components, enabling per-render caching across your component tree during a single request.
A classic example is a helper function like getUser:
// lib/data/user.ts
import { cache } from "react";
import { db } from "@/db";
export const getUser = cache(async (userId: string) => {
return db.getUser(userId);
});If you call getUser(id) across multiple server components within the same render pass, React only executes the database query once and shares the cached return value across all callers.
Why This Matters for Composition
When the same data is needed more than once during a server render, you should consider using the cache() API.
Local fetching lets you keep leaf components independent. Without cache(), we would be forced to hoist data fetching up to a common parent component and prop-drill the results down. Hoisting breaks composition and tightly couples unrelated components.
A common example in Next.js is dynamic metadata: you often need the same product or post data in generateMetadata() as you do inside the page component. Wrapping the query function in cache() prevents redundant database calls for that request.
However, cache() can also be used to preload data. Let's look at how to use it to eliminate server-side request waterfalls.
The Use Case: An Accidental Waterfall
Suppose we have a PostsPage in the Next.js App Router. It reads postId from the route params and renders an async server component called Post, wrapped in a <Suspense> boundary:
// app/posts/[postId]/page.tsx
import { Suspense } from "react";
import Post from "@/components/Post";
export default async function PostsPage({
params,
}: {
params: Promise<{ postId: string }>;
}) {
const { postId } = await params;
return (
<div>
<h1>Post: {postId}</h1>
<Suspense fallback={<div>Loading post...</div>}>
<Post postId={postId} />
</Suspense>
</div>
);
}Inside the Post component, we fetch the post data and render a nested <Comments /> component, which also fetches its own data inside a second <Suspense> boundary:
// components/Post.tsx
import { Suspense } from "react";
import Comments from "@/components/Comments";
import { getPost } from "@/lib/data";
export async function Post({ postId }: { postId: string }) {
const post = await getPost(postId);
return (
<div className="rounded border p-4">
<h2>Title: {post?.title}</h2>
<p>Post comments:</p>
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={postId} />
</Suspense>
</div>
);
}The Comments component fetches the discussion thread asynchronously:
// components/Comments.tsx
import { getComments } from "@/lib/data";
export async function Comments({ postId }: { postId: string }) {
const comments = await getComments(postId);
return (
<div className="rounded border p-4">
<h2>Comments</h2>
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
</div>
);
}The Problem
Both Post and Comments are server components, and each is responsible for fetching its own data.
However, because Comments is rendered inside Post, Comments cannot begin fetching until Post finishes awaiting getPost. Even though the comments query does not depend on the result of getPost (it only needs postId), it is blocked, resulting in a sequential network waterfall.
The Naive Approach: Hoisting with Promise.all()
A common way developers try to solve this is by hoisting data fetching up into the Post component and using Promise.all() to parallelize both queries:
// components/Post.tsx
export async function Post({ postId }: { postId: string }) {
// Fetch post and comments in parallel
const [post, comments] = await Promise.all([
getPost(postId),
getComments(postId),
]);
return (
<div className="rounded border p-4">
<h2>Title: {post?.title}</h2>
<p>Post comments:</p>
<Comments comments={comments} />
</div>
);
}While this eliminates the waterfall, it introduces notable architectural drawbacks:
- Tight Data Coupling: The
Postcomponent now has to know about comment fetching. If you later remove comments, you have to remember to remove the query fromPost. - Slowest Query Blocks Everything: If comment fetching takes longer than post data, the post cannot render its UI until the comments have resolved.
- Layout Blocking: If you hoist fetching into a root layout, your entire page is blocked from streaming.
The Solution: The Preload Pattern with cache()
Instead of hoisting data, we can keep components decoupled and use React 19's cache() API to preload queries.
First, wrap your data fetching functions in cache():
// lib/data.ts
import { cache } from "react";
export const getPost = cache(async (postId: string) => {
return db.post.findUnique({ where: { id: postId } });
});
export const getComments = cache(async (postId: string) => {
return db.comment.findMany({ where: { postId } });
});Because getComments is wrapped in cache(), calling it initiates the query and stores the pending promise for that request.
Now, we can initiate the prefetch in a higher component (such as PostsPage) without awaiting it:
// app/posts/[postId]/page.tsx
import { Suspense } from "react";
import Post from "@/components/Post";
import { getComments } from "@/lib/data";
export default async function PostsPage({
params,
}: {
params: Promise<{ postId: string }>;
}) {
const { postId } = await params;
// Initiate prefetch early, but DO NOT await the promise
getComments(postId);
return (
<div>
<h1>Post: {postId}</h1>
<Suspense fallback={<div>Loading post...</div>}>
<Post postId={postId} />
</Suspense>
</div>
);
}What Happens Under the Hood
PostsPagestarts executing and callsgetComments(postId)without awaiting it. The database query begins immediately in the background.PostsPagerenders its shell and renders<Post postId={postId} />.PostawaitsgetPost(postId).- When
Postfinishes and renders<Comments postId={postId} />,CommentscallsgetComments(postId). - Because
getCommentswas memoized withcache(),Commentsreuses the existing in-flight request triggered earlier, resolving almost instantly and skipping the waterfall.
Solving Hidden Coupling with Explicit Preload Functions
One caveat with calling getComments(postId) directly in parent components is hidden coupling during refactoring: if someone later removes the <Comments /> component from the tree, the prefetch in the parent might be forgotten and continue running as dead work.
A clean convention recommended in Next.js is to export an explicit preload helper from the component file itself:
// components/Comments.tsx
import { getComments } from "@/lib/data";
// Explicit preload helper
export const preloadComments = (postId: string) => {
void getComments(postId);
};
export async function Comments({ postId }: { postId: string }) {
const comments = await getComments(postId);
return (
<div className="rounded border p-4">
<h2>Comments</h2>
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
</div>
);
}Now in the parent, import and call preloadComments:
// app/posts/[postId]/page.tsx
import { preloadComments } from "@/components/Comments";
export default async function PostsPage({ params }: Props) {
const { postId } = await params;
preloadComments(postId);
return (
<div>
<Suspense fallback={<div>Loading post...</div>}>
<Post postId={postId} />
</Suspense>
</div>
);
}This makes it obvious to any engineer working on the file that this prefetch belongs specifically to the <Comments /> component.
When to Use cache() vs. fetch()
It is worth noting that native fetch() in Next.js is already deduplicated per-render automatically.
- If you are fetching data using
fetch('https://...'), Next.js already memoizes identical calls during a server pass. You do not need to wrapfetch()incache(). - The
cache()API is intended for direct database queries (Drizzle, Prisma, SQL drivers), third-party SDK calls, or expensive in-memory computations that don't pass throughfetch().
Modern Next.js 16: Preloading with "use cache: private"
In Next.js 16, the Cache Components architecture introduced another clean way to execute this pattern using Cache Functions.
For example, when reading authenticated user identity via cookies or sessions, you can define a private Cache Function:
// lib/auth/get-current-user.ts
import "server-only";
import { cacheLife } from "next/cache";
import { auth } from "@/lib/session";
export async function getCurrentUser() {
"use cache: private";
cacheLife({ stale: Infinity });
return auth.getUser();
}
export function preloadCurrentUser() {
void getCurrentUser();
}We can now initiate the user lookup at the top of a layout or page, and let deep leaf components (such as a <UserMenu />) reuse the cached result:
// app/dashboard/page.tsx
import { Suspense } from "react";
import Dashboard from "@/components/Dashboard";
import { preloadCurrentUser } from "@/lib/auth/get-current-user";
export default function DashboardPage() {
// Start the user lookup in parallel without awaiting
preloadCurrentUser();
return (
<Suspense fallback={<div>Loading dashboard...</div>}>
<Dashboard />
</Suspense>
);
}Inside deep child components, calling getCurrentUser() reuses the result of the parallelized request:
// components/UserMenu.tsx
import { getCurrentUser } from "@/lib/auth/get-current-user";
export async function UserMenu() {
const user = await getCurrentUser();
if (!user) return null;
return <p>{user.name}</p>;
}Setting cacheLife({ stale: Infinity }) ensures the request is kept alive for the current server render pass, allowing the user lookup to stream in parallel with your primary page content.
Key Takeaways
- The
cache()API enables per-render memoization: It lets multiple server components request the same data without redundant database executions. - Use preloading to eliminate waterfalls: Initiate queries in parent components without awaiting the returned promise, allowing nested child components to pick up the in-flight request.
- Export explicit
preloadhelpers: Co-locate your preload function with the component that consumes the data to avoid abandoned prefetches during refactors. - Use
cache()for databases and SDKs: Nativefetch()calls are already deduplicated by Next.js, so reservecache()for ORMs, database clients, and custom computations.
Conclusion
The cache() API and preloading pattern allow you to optimize React Server Component data fetching without sacrificing architectural modularity.
Instead of choosing between decoupled leaf components and fast network execution, you get both: server components remain self-contained, while preloading starts critical database queries early to eliminate waterfalls before rendering begins.