Data Fetching Patterns: Parallel, Waterfall, and Preloading
Server Components can be async functions. That single fact unlocks a style of data loading that
looks like ordinary application code: await in the component body, errors handled like any async
workflow, and no artificial split between “render” and “fetch” unless you want it for UX. The art is
choosing when work runs in parallel, when a waterfall is unavoidable, and when to
preload so dependent pages still hide latency behind Suspense.
Parallelism via sibling Suspense boundaries
React schedules independent async trees according to your component structure. A practical pattern:
wrap each async region in <Suspense> so slow segments do not block the whole page. Siblings
inside the same parent can resolve concurrently—the runtime does not need you to manually
Promise.all unless you prefer explicit control.
import { Suspense } from 'react'
// Pattern 1: Parallel Data Fetching via Suspense siblings
export default async function Dashboard() {
return (
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
)
}
From a TypeScript perspective, StatsPanel and RevenueChart might each return
Promise<JSX.Element> internally (if they are async server components) or contain async
children—the important part is boundary placement: each fallback corresponds to a latency budget
the user can understand (“stats” vs “chart”), not one global spinner for unrelated data.
When waterfalls are correct
Not all sequences should be parallelized. If the second query depends on identifiers from the
first, you have a true sequential dependency. Forcing Promise.all would be wrong; you need
the first result to parameterize the second.
// Pattern 2: Waterfall when data depends on prior fetch
async function UserProfile({ userId }: { userId: string }) {
const user = await fetchUser(userId)
const orders = await fetchUserOrders(user.id) // needs user.id first
return <div>...</div>
}
This is not a failure mode—it is honest modeling of your data graph. The UX fix is not always “parallelize anyway” but reshaping the backend (e.g., single aggregated query), denormalizing for read paths, or streaming the independent tail with Suspense once the prerequisite data arrives.
Optimization starts with recognizing spurious waterfalls (serial awaits with no dependency) versus real ones.
Preloading: start work before the await
Sometimes you need an initial record before you can render the hero section, but related reads
do not need to block one another once you know the key. Preloading means kicking off promises
before the await that would otherwise create an accidental waterfall, relying on a shared
cache (HTTP cache, Data Cache, or cache()) so the later consumer does not repeat work.
import { Suspense } from 'react'
// Pattern 3: Preloading for parallel fetching with dependency
async function ProductPage({ productId }: { productId: string }) {
preloadRelatedProducts(productId) // kick off fetch immediately
preloadReviews(productId)
const product = await fetchProduct(productId)
return (
<div>
<ProductDetails product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
</div>
)
}
function preloadRelatedProducts(productId: string) {
void fetchRelatedProducts(productId) // fire and forget — cache dedupes
}
The void prefix signals intentional fire-and-forget: you are warming the cache so ProductReviews
(or a shared data loader) hits a resolved promise sooner. This pairs naturally with
deduplication—if two parts of the tree request the same key in one request, you want a single
backend hit.
Request-scoped deduplication with cache()
React’s cache() memoizes a function for the lifetime of a single request/render pass.
Multiple components calling getUser(id) collapse to one execution. That is different from HTTP
caching or unstable_cache: it is purely in-memory dedupe so your component tree stays modular
without multiplying database round-trips.
// React cache() for per-request deduplication
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
// Multiple components calling getUser(id) → only ONE DB query per request
TypeScript tip: type the cached function’s return as a Promise<User | null> (or your domain type)
and reuse getUser anywhere—headers, sidebars, page body—without inventing prop-drilling hacks to
avoid duplicate fetches.
Explicit Promise.all when the tree does not express independence
Suspense-driven parallelism is powerful, but sometimes you want one async function that awaits
multiple independent queries before returning a single subtree—for example when you must compute a
derived value that depends on all of them. In that case, await Promise.all([a(), b(), c()]) inside
a server component or loader is appropriate. The trade-off is coarser streaming: you lose the
ability to show b’s UI while a is still pending unless you split components again. Choose
Suspense boundaries when UX benefits from partial rendering; choose Promise.all when the
atomicity of the result matters more than progressive disclosure.
Next.js fetch deduplication (and when it is not enough)
Within a single request, Next.js can deduplicate identical fetch calls automatically for
certain caching modes. That can feel magical when two distant components request the same URL. It is
not a substitute for cache() around ORM calls: database access does not go through fetch
unless you wrap it yourself. Treat framework dedupe as a nice extra for HTTP reads, and still
use cache() for shared Prisma/SQL access so your data layer stays independent of transport
details.
Putting it together
- Use sibling Suspense for independent slow regions.
- Accept waterfalls only when the data dependency is real; otherwise refactor or preload.
- Use preload helpers plus a shared cache to overlap IO with earlier awaits.
- Wrap hot loaders with
cache()to keep architecture modular and IO cheap per request.
These patterns do not replace good schema design or indexing, but they align React’s tree structure with how networks and databases actually behave—parallel where possible, sequential when necessary, and always explicit about what the user sees while each segment resolves.
When you profile a slow page, annotate each await with who depends on whom. Anything without a dependency arrow is a candidate for a sibling Suspense boundary or a preload warm-up. Anything with a real arrow is a candidate for a single backend query or a materialized read model—not for fake parallelism that only hides the waterfall behind more spinners.