
Next.js Prefetching
Every framework with client-side navigation makes some version of the same promise — "clicking a link feels instant" — and the mechanism behind that promise, whenever it's true, is prefetching: fetching a route's resources before the user asks for them, so the click just reveals something already sitting in memory rather than kicking off a fresh round trip. Next.js does this automatically, with no configuration required for the common case, but understanding exactly what it prefetches, when, and how to override it is what separates "navigation feels fast most of the time" from "navigation is reliably fast, and I understand why."
The mechanism, briefly
Traditional single-page apps load all their JavaScript upfront, which trades a slower initial load for instant subsequent navigation. Next.js splits the difference: your app is automatically code-split by route, so only the current route's code loads initially, while the rest loads in the background as links become relevant. Prefetching is what makes "in the background" actually happen ahead of the click rather than after it — by the time you actually click a link, its resources have typically already been pulled into the browser's cache, so the resulting navigation is a client-side transition rather than a full page reload with a loading spinner.
What gets prefetched automatically, and how much
This only runs in production — dev builds don't prefetch, which is worth remembering if you're ever timing navigation performance locally and wondering why it feels different from what users see. As each <Link> enters the viewport, Next.js schedules a prefetch for its destination, deliberately throttled so that a page full of links doesn't flood the network all at once.
How much gets prefetched depends on whether the destination is static or dynamic, and this split matters enough to be worth internalizing precisely (without Cache Components' Partial Prefetching enabled, which changes the model — covered below):
| Static page | Dynamic page | |
|---|---|---|
| Prefetched | Yes, full route | No, unless it has a loading.js boundary |
| Client cache TTL | 5 minutes by default | Off, unless explicitly enabled via staleTimes |
| Server round trip on click | No | Yes, streamed after the shell |
A static route gets prefetched wholesale and cached client-side for five minutes by default. A dynamic route without a loading.js file gets skipped entirely by the automatic prefetcher — there's nothing safe to prefetch ahead of time if the content is inherently request-specific and there's no defined boundary for what counts as the "shell" versus the "dynamic part." Add a loading.js, and Next.js prefetches everything from the layout down to that first loading boundary, though this content isn't cached by default the way static prefetches are — you'd need to opt in via staleTimes if you want it retained.
One detail worth knowing about the payload itself: the very first navigation to any route fetches full HTML, JavaScript, and the RSC payload. Every navigation after that — soft, client-side transitions — only needs the RSC payload for Server Components and the JS bundle for Client Components, since the shell infrastructure (layout, root document) is already in place.
Scheduling: not everything prefetches at once
Next.js maintains a small internal task queue rather than firing every possible prefetch simultaneously, prioritized in this order: links currently in the viewport first, links showing explicit user intent (hover or touch) next, newer link visibility replacing older queued requests, and anything scrolled entirely off-screen gets discarded from the queue rather than prefetched pointlessly. The intent is straightforward — spend the limited prefetch budget on navigations that are actually likely, not on every link that ever technically entered the viewport during a long scroll session.
Worth knowing if you're using the experimental offline-support feature: with useOffline enabled, prefetches that were pending when connectivity dropped resume through this same queue once the app recovers, rather than being lost entirely.
The client cache and shared layouts
Prefetched RSC payloads live in an in-memory client cache, keyed by route segment — which has a genuinely useful side effect for nested routes. Navigating between sibling routes that share a parent layout (/dashboard/settings → /dashboard/analytics, say) reuses the already-rendered parent layout entirely and only fetches the specific leaf page that actually changed. This is why navigating within a shared layout structure tends to feel meaningfully snappier than navigating to a completely unrelated part of the app — there's genuinely less work happening, not just less perceived work.
Partial Prefetching changes the model entirely
Everything above describes the all-or-nothing default. With partialPrefetching enabled (which itself requires Cache Components), prefetching shifts to a fundamentally different unit: a single, reusable App Shell per route, rather than a full-or-nothing prefetch per link.
The practical consequences: one shell — covering a route's static output plus any session-specific content — gets prefetched once, the moment the first link to that route enters the viewport, and every subsequent link pointing at the same route reuses that same shell rather than triggering its own separate prefetch. A page with fifty links to the same destination makes meaningfully fewer prefetch requests under this model than the classic full-prefetch-per-link approach would.
Anything genuinely uncached streams in after the actual navigation, behind whatever <Suspense> boundaries the shell defines — and if you need URL-specific data (searchParams, dynamic params) resolved before the click rather than streamed after it, that's what prefetch={true} on a specific link is for, covered in depth in the companion "Optimizing prefetching" article. Data invalidations via revalidateTag or revalidatePath also silently refresh any prefetches tied to that data, so a stale shell doesn't linger past its actual cache lifetime.
If you're migrating an existing app into this model, the "Adopting Partial Prefetching" guide is the dedicated resource for the behavior changes and adoption sequencing — this article covers the steady-state behavior, not the migration path.
Controlling prefetching yourself
The defaults above are genuinely good defaults, but three escape hatches exist for when they don't fit your specific resource budget or navigation pattern.
Manual prefetch via router.prefetch()
For warming a route outside of a <Link>'s own viewport-triggered behavior — in response to an analytics signal, a custom hover target, or scroll position — useRouter exposes prefetch() directly:
"use client";
import { useRouter } from "next/navigation";
import { CustomLink } from "@components/link";
export function PricingCard() {
const router = useRouter();
return (
<div onMouseEnter={() => router.prefetch("/pricing")}>
<CustomLink href="/pricing">View Pricing</CustomLink>
</div>
);
}
Hover-triggered prefetch, for high-link-density pages
If your default <Link>'s viewport-based prefetching is firing too aggressively — a long list, an infinite-scroll table, a card grid — deferring until actual hover intent narrows the prefetch set to links a user is genuinely likely to click:
"use client";
import Link from "next/link";
import { useState } from "react";
export function HoverPrefetchLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const [active, setActive] = useState(false);
return (
<Link
href={href}
prefetch={active ? null : false}
onMouseEnter={() => setActive(true)}
>
{children}
</Link>
);
}
The prefetch={active ? null : false} toggle is doing something specific worth understanding: false disables prefetching entirely until hover, and null — not true — restores the default static-prefetch behavior once intent is shown, rather than forcing a specific mode. The docs are explicit that extending <Link> this way opts you into maintaining prefetching, cache invalidation, and accessibility behavior yourself going forward — treat it as something to reach for when the defaults are genuinely insufficient, not a pattern to apply reflexively across a whole app.
Disabling prefetch entirely for specific links
Sometimes the right answer is just "don't prefetch this," full stop — footer links are the classic example, since they're rarely the target of an intentional, imminent navigation:
"use client";
import Link, { LinkProps } from "next/link";
function NoPrefetchLink({
prefetch,
...rest
}: LinkProps & { children: React.ReactNode }) {
return <Link {...rest} prefetch={false} />;
}
Be aware of the trade-off before reaching for this broadly: disabling prefetch means static routes only fetch on click (a real, felt delay that didn't exist before), and dynamic routes wait on a full server render before the navigation can even begin.
Ejecting from <Link> entirely
For genuinely custom prefetch strategies — cursor-direction prediction via a library like ForesightJS, for instance — you can recreate <Link>'s core behavior manually with useRouter, including handling cache-invalidation signals yourself via the onInvalidate callback:
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
function ManualPrefetchLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const router = useRouter();
useEffect(() => {
let cancelled = false;
const poll = () => {
if (!cancelled) router.prefetch(href, { onInvalidate: poll });
};
poll();
return () => {
cancelled = true;
};
}, [href, router]);
return (
<a
href={href}
onClick={(event) => {
event.preventDefault();
router.push(href);
}}
>
{children}
</a>
);
}
Note the preventDefault() inside onClick — a bare <a> tag would otherwise trigger a genuine full-page navigation, defeating the entire purpose of a manually-managed prefetch/soft-navigate pair. This is the heaviest of the three escape hatches, appropriate only when you're building something the built-in behaviors genuinely can't express.
Troubleshooting
Side effects firing during prefetch, not on actual visit
If a layout or page isn't a pure function of its props — commonly, an analytics call fired directly in the component body — that call can fire when the route is merely prefetched, not when a user actually navigates there. This produces analytics data that overcounts page views relative to reality, and it's a genuinely easy mistake to make since nothing about the code looks wrong in isolation.
// Before — runs during prefetch, not just on real visits
import { trackPageView } from "@/lib/analytics";
export default function Layout({ children }: { children: React.ReactNode }) {
trackPageView();
return <div>{children}</div>;
}
The fix is moving the side effect into a useEffect inside a small Client Component, so it only fires on an actual client-side mount rather than during server-side prefetch rendering:
"use client";
import { useEffect } from "react";
import { trackPageView } from "@/lib/analytics";
export function AnalyticsTracker() {
useEffect(() => {
trackPageView();
}, []);
return null;
}
import { AnalyticsTracker } from "@/app/ui/analytics-tracker";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<AnalyticsTracker />
{children}
</div>
);
}
Too many prefetches happening at once
A large list of links — an infinite-scroll table is the canonical case — can trigger a prefetch for every single row the moment it scrolls through the viewport, which is wasted bandwidth for rows a user will never click. prefetch={false} on those specific links is the blunt fix; the hover-triggered pattern above is the more surgical one, narrowing the prefetch set down to links with actual demonstrated intent rather than mere visibility.
Key Takeaways
| Question | Answer |
|---|---|
| Runs in dev mode? | No — production only |
| Static route default | Prefetched in full, cached 5 minutes client-side |
| Dynamic route default | Skipped unless it has loading.js |
| With Partial Prefetching | One shared App Shell per route, not per link |
Warm a route outside <Link> | router.prefetch() |
| Too many prefetches firing | prefetch={false} or hover-triggered prefetch |
| Side effects firing too early | Move them into a useEffect-based Client Component, not the layout/page body |
The default behavior here genuinely is good enough for most apps without any tuning at all — Next.js is already doing the throttled, viewport-aware, cache-respecting prefetch work that a lot of other frameworks leave entirely to you. The controls covered above exist for the specific, real cases where the default doesn't fit — high-density link grids, custom prefetch heuristics, resource-constrained footers — not as a checklist to apply everywhere out of caution.


