I read the Next.js 16.4 release post on Tuesday night, and the email inbox example made me wince. It describes a mistake I know I would make. You add prefetch to a list of links because the loading spinner bugs you, the spinner goes away, and you feel clever. Then you never look at what your database is doing for every link that scrolls into view.
Short version for the impatient: 16.4 adds await navigation() and prefetch() to next/cache, which let you leave expensive cached content out of a prefetch and load it only when someone actually clicks. If you have a list page where each row links to a heavy detail page, this is the feature to read about first. The rest of the release is fine, but this one changes how I would write those pages.
I haven't shipped 16.4 to a client yet, so treat what follows as a close reading of the official release post plus my own reasoning about cost. Where I'm guessing, I'll say so.
The prefetch bill nobody sends you
Start with what <Link prefetch> does under Cache Components. Per the release post, it prerenders a page's cached UI before the navigation happens, so the click feels instant. For one link, great. For a page with fifty links, every link that becomes visible triggers that work.
The post's own example is an inbox. Each row links to /message/[id], and the message page loads two cached things: the message itself and the whole thread. With prefetch on the links, scrolling past fifty messages loads fifty threads. The post says it plainly: "every visible link loads the entire message thread ahead of time". That includes threads for messages the user never opens.
That is a cost problem, not a correctness problem. Nothing breaks. The page feels great in dev with ten seeded rows. Then imagine production, where a user has 4,000 messages and the list scrolls fast. Your database gets a polite flood of reads from people who were only scrolling.
Here is the version most of us would write first:
// app/message/[id]/page.jsx
async function Message({ id }) {
const [message, thread] = await Promise.all([
getMessage(id),
getThread(id), // also cached with 'use cache'
]);
return (
<>
<p>{message.subject}</p>
<div>{message.body}</div>
{thread.map((m) => (
<div key={m.id}>{m.body}</div>
))}
</>
);
}
Both getMessage and getThread carry 'use cache', so they are eligible for the prefetch. That's the whole problem. Caching made them prefetchable, and prefetchable means they run for links nobody clicks.
What the old escape hatch cost me
Before 16.4, your options were blunt. You could turn prefetch off on the links and accept the spinner. You could split the data fetching by hand and invent your own "load more on click" client component. Or you could leave prefetch on and hope your database was bored.
The second route is the one I see most often in other people's codebases, and I never liked it. You end up with a client component that fetches in an effect, which throws away the point of server rendering for that chunk of the page. You also get two code paths for the same data, one for the first paint and one for the deferred part, and they drift apart within a month.
The release post frames the 16.4 answer as a better solution than "just turning prefetching back off": only prefetch the first message, and defer rendering the rest of the thread until the user actually navigates. That's the right instinct. Keep the instant feel for the part people see first. Pay for the rest only when someone shows intent.
How await navigation() works
The fix is small. You pull the expensive part into its own component and call await navigation() at the top of it:
import { Suspense } from 'react';
import { navigation } from 'next/cache';
async function Message({ id }) {
const message = await getMessage(id);
return (
<>
<p>{message.subject}</p>
<div>{message.body}</div>
<Suspense fallback={<Spinner />}>
<Thread id={id} />
</Suspense>
</>
);
}
async function Thread({ id }) {
await navigation();
const thread = await getThread(id);
return thread.map((m) => <div key={m.id}>{m.body}</div>);
}
During a prefetch, Next renders the message and stops at the Thread boundary. The fallback is what lands in the prefetched shell. When the user actually navigates, the thread loads and streams in. So the first message is instant, and the thread costs you nothing until someone opens it.
Two details matter here. The Suspense boundary is not optional decoration. The deferred part needs somewhere to show its fallback, so skipping it defeats the point. And navigation() sits at the top of the component, before any data call. If you await your query first and the navigation gate second, the query has already run. I'd put it on line one and leave a comment, because a teammate will eventually "tidy" the order.
The release post also adds prefetch(), which you await to exclude cached content that would otherwise be part of a route's shell, so that content waits for an explicit prefetch with <Link prefetch> or useRouter().prefetch(). That's a different lever from navigation(), and the API references are where I'd go to see which stage fits which content. I haven't found a case yet where I'd reach for it first.
When I would not use it
I'm suspicious of any feature that makes a spinner come back, so here is where I'd hold off.
If the deferred part is the reason people open the page, don't defer it. A message thread is borderline: people open a message to read the thread. Deferring it means every click shows a spinner for the main content, which can feel worse than a slightly slower first paint. For the inbox that's fine, because the first message is usually most of what you need. For a report page where the chart is the whole page, it would be backwards.
If your list is short, the saving is noise. Ten links with a prefetch each is not a bill worth engineering around. I'd measure before adding the gate, and "measure" here means counting database reads per list render with prefetch on, not trusting a feeling.
And if the deferred component is cheap and cached for hours, the prefetch is already close to free. The gate only pays off when the work behind a link is expensive and most links never get clicked. That second condition is the one people forget.
ensureStatic, the other thing I'll actually use
The other 16.4 feature I'd copy into a project on day one is ensureStatic. You export it from a route, and Next fails the build if dynamic content sneaks in:
export const ensureStatic = 'navigation';
export default function Page() {
return (
<>
<UserAvatar /> {/* this fails the build */}
<Content />
</>
);
}
The release post describes it as a guard for sites like stores, marketing pages and blogs, where one dynamic component can quietly turn an otherwise static route into a request-time one. I like it because it moves a performance regression from "someone notices the bill" to "CI goes red". You can then push the setting down into nested layouts if some pages legitimately need dynamic bits later.
This is the same instinct I wrote about when I checked the use cache gap before moving a Next app to Vite: I want the framework to tell me when my caching assumptions are wrong, rather than finding out in production. ensureStatic is that for static routes. await navigation() is that for prefetch cost, at least in spirit, since it makes you name the expensive part.
Smaller things that touch your daily loop
A few other changes from the post are worth a line each, because they change what next dev feels like. Turbopack's disk cache uses 20 to 25 percent less space, thanks to Zstandard compression for the bulk of the data. Server HMR is lazier: when you change a shared server module, Turbopack applies updates only to routes that get requested again, and the page you're looking at still updates as you type. Production CSS Module class names get shorter, and export mangling trims bundle size further. All of that arrives with no config.
The Rust React Compiler is still experimental, and I'd leave it off for client work. The post reports a 30 percent memory drop and 15 percent faster compile from allocation changes, which is nice, but "experimental" in a flag name means I get to be the one who debugs it at 11pm. I wrote about the Turbopack side in my Next.js 16.3 chunking post if you want that background.
There's also an --agent option on next upgrade that prepares migration guides and codemods for a coding agent. I haven't tried it. I'd run it on a branch with the test suite green before and after, and I'd read the diff, because "the agent applied the update" is a sentence I want to verify rather than trust.
What I'd do this week
Open your busiest list page, the one where every row links to something heavy. Turn on query logging in dev and scroll the list with prefetch enabled. Count the reads. If the number embarrasses you, split the heavy part of the destination page into its own component, add await navigation() on its first line, and wrap it in Suspense.
Then measure again. If the reads dropped and the click still feels fast, keep it. If the page now flashes a spinner for the content people came for, undo it and defer something smaller. That's the whole experiment, and it fits in an afternoon. If you want to see how I think about this kind of cost trade-off on client projects, the rest of my work is at abrarqasim.com.
Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.
Top comments (0)