Next.js Preload Pattern with React cache() (2026 Guide)
The Next.js preload pattern uses React cache() and a void preload() helper to eliminate server component fetch waterfalls. Learn the DAL setup, Suspense streaming, and ORM memoization tricks that shave 300-900ms off real routes.
The Next.js preload pattern is a data-fetching technique that starts server-side requests before a Server Component renders, so the fetch time overlaps with other work instead of stacking behind an await. You wrap a request in React's cache(), expose a fire-and-forget preload() helper, and call it at the top of a parent route. When the child component finally awaits the same call, the response is already sitting in the request-scoped cache. I've watched this trick shave 300–900ms off routes in production, and it works because it turns sequential fetch waterfalls into parallel ones without any client-side JavaScript.
The Next.js preload pattern uses React's cache() to memoize a fetch and a void preload() helper to start it early, killing off server component fetch waterfalls.
fetch() is memoized per request automatically, but ORM calls like Prisma and Drizzle are not, so you need to wrap them in cache() yourself.
server-only guards the module so a rogue client import throws at build time instead of leaking secrets into the browser bundle.
Combine preload() with a Suspense boundary so fast content streams first while the slow query resolves in the background.
Promise.all is faster when both queries are independent and always needed; preload() wins when a slow query lives behind a conditional or several component levels deep.
React cache() is request-scoped, so it never leaks between users. That means you can safely put authenticated queries inside it.
What is the preload pattern in Next.js?
The preload pattern is a small utility function (typically two lines) that eagerly kicks off a data request without awaiting the result. It relies on React's cache() function to memoize the call, so when a downstream Server Component eventually awaits the same request, it gets the cached result rather than issuing a second round trip. The pattern is officially recommended by Vercel and it's core to the Next.js App Router's data-fetching story in 2026.
Two things are doing the heavy lifting here. First, the void operator, which discards the returned Promise so TypeScript doesn't complain about an unhandled promise. Second, the wrapped getItem function, which is a cache()-decorated fetcher. The moment you call preload(), the underlying fetch fires. When the actual awaiting component renders later, React sees the identical arguments and hands back the in-flight Promise. No duplicate network call, no extra latency.
This matters because Server Components render top-down and each await is a blocking gate. The preload pattern pries that gate open.
Why server component fetch waterfalls happen
Here's the shape I see over and over in code reviews. A parent Server Component fetches something (say, the user's session), and inside it renders a child that fetches something else entirely, like a list of posts. The two queries have nothing to do with each other, but the child cannot start its fetch until the parent's await resolves. React is not clairvoyant. It has to run the parent's function body before it discovers the child even exists.
Total server time: 700ms. If you open the Vercel deployment's function log and expand the trace, you'll see two spans stacked end-to-end rather than side-by-side. In my Chrome DevTools Performance panel, that shows up as a single flat waterfall bar, with TTFB pushed out by whichever query is slowest, plus every query above it in the render tree.
The fix is to start getPosts() at t=0, in parallel with getUser(), so total time collapses to max(220, 480) = 480ms. That's what the preload pattern does. And it does it without needing the parent to know anything about the child's data requirements.
Building a data access layer with React cache() and server-only
Before you write any preload helpers, put your queries behind a Data Access Layer (DAL). Vercel's own guidance is to route every read through a centralized module that lives on the server, enforces authorization, and returns minimal DTOs. It's the same idea you'd apply in a Rails app or a Django project. You just do it with server components instead of view helpers.
Here's the shape I use on production apps:
// lib/dal/posts.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
export const getPost = cache(async (id: string) => {
const session = await auth()
if (!session) throw new Error('Unauthorized')
const row = await db.query.posts.findFirst({
where: (p, { eq }) => eq(p.id, id),
})
if (!row) return null
// Return a DTO, never the raw row
return {
id: row.id,
title: row.title,
body: row.body,
authorId: row.authorId,
}
})
export const preloadPost = (id: string) => {
void getPost(id)
}
A few things worth pointing out. First, import 'server-only' at the top: if any client component or module in the client graph accidentally imports this file, the build fails with a clear error rather than silently shipping your database driver to the browser. Second, cache() is from React, not Next.js. It's request-scoped and lives for the duration of a single server request. Third, the DTO shape: you decide what leaves the module, not the schema.
For a deeper dive on wiring the ORM side, see my Drizzle ORM guide for Next.js, which covers schema, migrations, and connection pooling.
How does the void preload() pattern work?
With the DAL in place, the preload call is trivial. You put it at the top of the highest component that could benefit from starting the fetch early (usually the page or a layout) and forget about it. The downstream component still awaits normally.
When you profile this in the Vercel Functions dashboard, you'll see getPost and getAnalytics start at nearly the same timestamp. In my own before/after (a real app I profiled earlier this year), the route went from 640ms server time to 410ms with a single line change. That's not a micro-optimization; it's the difference between "snappy" and "sluggish" LCP, which matters a lot if you're chasing the Core Web Vitals thresholds I wrote about in the useReportWebVitals guide.
When Promise.all beats preload, and when it doesn't
Promise.all is the other tool for parallelizing async work, and it's often the right one. The two approaches solve overlapping but distinct problems, so pick based on where the data is actually used.
Situation
Promise.all
preload() pattern
Both queries needed in the same component
✓ Cleaner, keeps data local
Works but adds indirection
Data used by a deeply nested child
Requires prop drilling
✓ Child fetches from DAL directly
Query lives behind a conditional
Wastes work if branch not taken
✓ Fires only when preload() is called
Data needed by a Client Component via use()
Awkward: must pass Promise as prop
✓ DAL handles memoization
Error handling
Fails fast; one reject kills all
Errors surface where you await
Best default for a page-level layout
When shape is fixed
When components are composable
My rule of thumb: reach for Promise.all first if two queries live in the same file and are both always needed. Reach for preload() the moment the data is consumed by a child component you don't directly control, or when a fetch lives behind a conditional. In practice, most real routes end up using both.
Combining preload with Suspense for streaming
The preload pattern gets even better when you pair it with Suspense boundaries. Instead of blocking the entire route on the slowest query, you let Next.js stream the fast content first and stream the slow content in later, while the preloaded fetch is already flying.
// app/posts/[id]/page.tsx
import { Suspense } from 'react'
import { preloadPost } from '@/lib/dal/posts'
import { preloadComments } from '@/lib/dal/comments'
import { PostBody } from './PostBody'
import { Comments } from './Comments'
import { CommentsSkeleton } from './CommentsSkeleton'
export default async function PostPage({ params }) {
const { id } = await params
preloadPost(id) // fast, ~40ms
preloadComments(id) // slow, ~380ms
return (
<article>
<PostBody postId={id} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={id} />
</Suspense>
</article>
)
}
The post body renders as soon as its query resolves. The <Suspense> boundary sends a skeleton down the wire immediately, and the actual comments stream in when the (already-in-flight, thanks to preload) query resolves. Skip the preloadComments line and the comments fetch doesn't start until React tries to render <Comments />, which happens after<PostBody /> resolves. That's a hidden waterfall inside a Suspense boundary, and honestly, it's a subtle one to spot.
I go deep on streaming mechanics in the Streaming and Suspense guide if you want to see how the RSC payload actually flushes to the client. The gist: preload feeds the queries; Suspense chooses when to reveal them.
ORM calls (Prisma, Drizzle) are not memoized automatically
This one trips up almost everyone who moves from fetch() to a real database. Next.js patches fetch() so it's deduplicated per request. Call the same URL twice and you pay for one round trip. That patching does not extend to Prisma, Drizzle, Kysely, or raw pg queries. Call db.query.posts.findFirst({ where: eq(posts.id, id) }) twice in the same request and you pay twice. I hit this exact bug shipping a dashboard where the same user query fired six times per render. Wildly embarrassing in the trace.
The fix is to wrap every read function in your DAL with cache(), exactly like the getPost example earlier. Primitive-argument functions (ids, slugs, boolean filters) memoize cleanly. If you need to pass an object like a filter spec, either destructure it into primitives or hash it into a string key first. React's cache() is a request-scoped equivalent to lodash.memoize. It's not the persistent Data Cache and it does not survive across requests, which is exactly what you want for authenticated queries.
How do you fix fetch waterfalls in Next.js?
When I audit a slow Next.js route, I open the Network tab, filter by Fetch/XHR, and look at the timing waterfall on the initial document response. If server requests are stacked diagonally rather than starting at the same timestamp, you have a waterfall. Here's my go-to checklist:
Move fetches to Server Components. Client-side useEffect fetches always run after hydration, adding a full round trip and a bunch of client JavaScript. Server Components fetch before the response is even sent.
Wrap ORM reads in cache(). Otherwise the same query fired from two components hits the database twice.
Add preload() at the highest render boundary that knows the arguments. Usually that's the page or a layout. Preloading at a route group's layout means the fetch starts while Next.js is still resolving the parallel route tree.
Wrap slow content in <Suspense> with a real skeleton. Don't gate LCP on a 400ms comments query.
Prefer Promise.all for co-located queries. If a single component genuinely needs two things, await Promise.all([a(), b()]) is more honest than manual preload plumbing.
Look at the Vercel trace, not just DevTools. Server timing on the client only shows total TTFB. The function trace shows each database span.
Preload pattern pitfalls I've hit in production
Two years of shipping this pattern. Here are the traps.
Orphaned preloads. Someone deletes a child component but leaves the preloadX(id) call in the parent. Now you're paying for a query no one reads. My convention: keep the preloadX call directly above the JSX that eventually consumes it, and use ESLint's no-unused-vars plus a codeowner review to catch dangling calls when children get removed.
Preloading with the wrong arguments. If your child computes a derived id from a parent's data (for example, latestPost.authorId), you can't preload it. You don't know the value yet. Trying to preload with a placeholder just does an extra query. In that case, use Suspense boundaries and accept the waterfall inside the boundary.
Confusing cache() with unstable_cache or "use cache". React's cache() is request-scoped. Next.js's "use cache" (the successor to unstable_cache) persists across requests and integrates with revalidation tags. They solve different problems. My Next.js 16 Cache Components guide covers the persistent side; cache() here is only about avoiding duplicate work within a single render.
Passing objects to a cache()-wrapped function. React's memoization is reference-based. Calling getUser({ id: 1 }) twice creates two different object references, so you get two calls. Pass a primitive. If you must pass structured filters, hash them into a stable string first.
For the official guidance on how the DAL fits into a broader security model, Vercel's Data Security guide is the canonical reference, especially the section on tainting objects to prevent server-only values from leaking into client props. And the Next.js data fetching docs include a version of the preload snippet as the recommended pattern for any non-trivial route.
Frequently Asked Questions
Is fetch deduplicated in Next.js?
Yes. Next.js patches the global fetch() in Server Components so identical requests within the same server render are memoized automatically. This deduplication is request-scoped, so it never crosses users, and it applies to native fetch only, not to database drivers or ORM query builders.
Does React cache() persist across requests?
No. React's cache() is scoped to a single server render pass. It's cleared when the request completes, which is exactly what you want for authenticated queries. For persistent caching across requests, use Next.js's "use cache" directive or the older unstable_cache, which integrate with revalidation tags.
Should I preload data in every server component?
No. Only where a query lives behind a nested child, a conditional, or an await that would otherwise block it. If two queries sit side by side in one component, Promise.all is cleaner. Preload adds indirection, so use it when it actually breaks a waterfall.
Can I use the preload pattern with Server Actions?
Yes, but it's rarely useful. Server Actions run for a single mutation and typically don't have downstream components needing the same read. The pattern shines in read-heavy Server Component trees. In an Action, wrap reads in cache() if you call the same query more than once, but skip the preload() helper.
What's the difference between preload and React's use() hook?
preload() starts a fetch on the server; use() is a React hook that unwraps a Promise on the client (or in a Server Component). They compose: preload the data on the server, pass the Promise down as a prop, and let a Client Component call use(promise) to suspend on it inside a boundary.
Ship Next.js 16 on Cloudflare Workers with the @opennextjs/cloudflare adapter. Bindings, KV cache, images, Postgres pooling, and honest cost math vs Vercel.
Server-side feature flags in Next.js 16 without client bundles or flash-of-wrong-variant. Walk through install, Edge Config storage, precomputation for static routes, percentage rollouts, kill switches, and how the SDK compares to LaunchDarkly, Statsig, and GrowthBook.
Learn how to install Better Auth in Next.js 16, wire up Drizzle or Prisma, add email/password plus Google and GitHub social login, and lock down routes with proxy.ts and Server Action session checks.