Skip to content
intermediate Phase 14 · Images, Fonts, and Performance

Router Prefetching Behavior

Understand how links warm caches during idle time.

35m
0 problems
Topic Progress 0%

Production-Only Warmup: What Happens When a Link Enters the Viewport

Run the app with next dev, open the Network panel, scroll past a navigation bar full of <Link> elements — nothing happens. Run next build && next start, repeat the scroll, and each visible link quietly fires requests for a route you never clicked. Prefetching is production-only, and it is the single biggest reason App Router navigation feels like there is no navigation at all.

When a <Link> enters the viewport — or the user hovers or touches it — Next.js fetches the destination's assets in the background: HTML on first contact, the route's JavaScript chunks, and the React Server Components payload. They land in an in-memory client cache keyed by route segments, so the eventual click is a client-side transition: no document reload, no network wait.

The scheduler is deliberate, because a page with 50 links must not flood the connection:

  1. links in the viewport
  2. links showing user intent (hover or touch)
  3. newer requests replace older ones
  4. links scrolled off-screen are discarded

How much of each destination is fetched depends on the route type — the table interviewers like:

Destination What gets prefetched Client cache TTL
Static route the full route, everything 5 minutes (staleTimes.static default)
Dynamic route with loading.js the layout down to the first loading boundary off by default (staleTimes.dynamic)
Dynamic route with no loading.js skipped entirely —

Hover a link whose payload has expired and Next attempts the prefetch again before you click.

Cache granularity is why sibling navigation is nearly free: going from /dashboard/settings to /dashboard/analytics reuses the cached parent layout and fetches only the changed leaf page — the first visit to a section pays for the shared shell, every sibling after it does not.

The requests are easy to spot once you know the shape: a first navigation fetches HTML, the route's chunks, and the RSC payload; later navigations fetch only the RSC payload for server components plus JS for client components. In DevTools they carry the RSC: 1 header, and a cached payload means back-and-forward through recently visited routes answers from memory instead of the network. The cache is in-memory and per tab — not a service worker — so a hard reload always re-pays for the current route.

Then the trap that catches production teams: layouts and pages are React components, and React runs them — including during a prefetch. A trackPageView() called in a layout body fires when a link to that route merely scrolls past, and your analytics starts counting visits that never happened:

// ✗ runs during prefetch — records page views for links nobody clicked
export default function Layout({ children }: { children: React.ReactNode }) {
  trackPageView()
  return <div>{children}</div>
}

// ✓ runs only when the component mounts as part of a real visit
'use client'
export function AnalyticsTracker() {
  useEffect(() => { trackPageView() }, [])
  return null
}

The fix is a rule, not a patch: server components stay pure, and side effects move into a client effect or a Server Action that a user gesture triggers. The same purity rule is why prefetching is safe to run speculatively at all — if rendering a route could send an email or write a row, you have already built it wrong. (The same discipline governs measurement: whatever you load through Loading Scripts with next/script has to tolerate being mounted speculatively, and a view recorded from a prefetch is a view that never happened.)

All of which leaves the interesting case: what does "the route" even mean when the URL contains a segment the server has never seen?

`[slug]` Links and `loading.js`: What Dynamic Segments Actually Prefetch

A blog lists 40 posts, every one wrapped in <Link href={/blog/${post.slug}}>. Those destinations are dynamic segments, and their content is exactly what you do not want to compute for links nobody clicks. The router's answer is the loading boundary:

app/
├── blog/
│   ├── layout.tsx        ← prefetched: shared shell
│   ├── loading.tsx       ← prefetched: this is the ceiling
│   └── [slug]/
│       └── page.tsx      ← NOT prefetched; renders after the click

With loading.tsx in place, each visible link prefetches the shared layout down to the first loading boundary — a few KB of shell — and the post body streams in after the click, with the loading UI appearing instantly instead of the old page hanging on screen. Without one, the dynamic route is skipped during viewport prefetching and the click pays a full server round trip. Adding that one file is often the highest-leverage change for perceived navigation speed.

The mechanism behind the boundary: loading.tsx behaves like something Next can render without the page. The shell is producible ahead of time, so the prefetched payload contains something useful even though page.tsx is waiting on data. Remove the boundary and the only complete answer to "render /blog/hello-world" requires the page itself — nothing partial is worth shipping, so the router skips the link entirely. The same file then fixes the click: the shell paints immediately while the real page streams in behind it, rather than the previous route sitting frozen until the server answers.

The prefetch prop overrides the default per link:

// default: null / "auto" — static routes full, dynamic routes down to loading.js
<Link href="/pricing">Pricing</Link>

// force the full route for both kinds, including URL-data-dependent content
<Link href={`/search?q=${q}`} prefetch={true}>Search {q}</Link>

// never prefetch — not on viewport entry, not on hover
<Link href={`/blog/${post.slug}`} prefetch={false}>{post.title}</Link>

Under the classic model (no Cache Components), prefetch={true} simply means: prefetch the full route for static and dynamic destinations — the opposite of the default's partial dynamic behavior. The three values compose with nothing else: null/'auto' is default behavior, true is everything, false is nothing including hover re-prefetch. A conditional such as prefetch={isFeatured ? true : 'auto'} is the documented way to upgrade one link without touching the rest.

true is not "the default, harder". With Partial Prefetching enabled (partialPrefetching plus Cache Components) it resolves the link's URL data — searchParams, params — before the click, so /search?q=react can render real results instead of the Suspense fallback. Either way the cost is a server invocation per visible link: put it on a grid of 24 product cards and you have requested 24 server renders regardless of clicks. The docs' guidance matches the arithmetic — use it where URL-data-dependent content has an expressible cache lifetime and the traffic justifies the per-link cost; skip it when the App Shell already makes the navigation instant.

Two escape hatches when viewport-prefetching is the wrong model — a table of 500 rows, a metered connection:

'use client'
const [active, setActive] = useState(false)

// no prefetch until the user shows intent; null restores the default behavior
<Link href={href} prefetch={active ? null : false} onMouseEnter={() => setActive(true)}>
  {children}
</Link>
const router = useRouter()
// warm a route from analytics, a keyboard shortcut, a custom component:
onMouseEnter={() => router.prefetch('/pricing')}

prefetch={null} is the default, so the hover pattern layers intent on top instead of reimplementing the router. router.prefetch() covers anything outside a <Link> — and for genuinely expensive subtrees, Next's prefetch() and navigation() helpers from next/cache defer that work to the per-link prefetch stage or the click itself, rendering the nearest Suspense fallback until the stage runs.

You can see which case you are in without reading docs: production build, Network tab, and one request per visible link carrying the RSC: 1 header. A tiny payload means the shell arrived and the page is deferred — the loading boundary working as intended. No request at all means the route was skipped: dynamic, no loading.js. A large payload fired for every card in a grid means someone reached for prefetch={true} and is paying a server render per card.

Whether a payload stays warm, how long it survives, and what happens after you mutate the data behind it are budget questions — the subject of the next chapter.

When Prefetching Hurts: Giant Lists, Stale Payloads, and Verifying It Works

Prefetching spends two currencies — your visitor's bandwidth and your server's compute — and the defaults can overspend both.

Bandwidth. An infinite-scroll table of 2,000 links is 2,000 prefetches as rows stream past. The scheduler throttles and discards off-screen work, but it cannot know your rows are worthless. <Link prefetch={false}> on list-heavy UI (the docs' own example is an infinite scroll table; footers are the other classic) restores click-time fetching for those links only. What you give up, concretely: static routes become fetch-on-click, and dynamic routes wait for a server render during navigation instead of after it.

Compute. Default prefetches are cheap because for dynamic routes they are shell-only. The expensive option is per-link: each visible prefetch={true} link can wake a server to render content keyed on a URL parameter that link alone carries. The trade, compressed from the docs:

Scope Cost bounded by Contains
default (shell / partial) number of routes static and session output shared by every link into the route
prefetch={true} visible link count the shell plus that link's URL data
the click navigations everything left, deferred work included

A grid full of prefetch={true} therefore issues one server request per card as the grid enters the viewport. If the navigation would look the same either way, you paid for nothing — prefetch on intent instead (the hover pattern from the previous chapter).

Freshness. A prefetched payload sits in the client cache: 5 minutes for static routes by default, effectively not at all for dynamic ones, with staleTimes tuning both. Stale-after-mutation is the classic bug — publish a post, navigate back to the list, see the pre-mutation version for up to five minutes. Fix it at the cache layer (revalidateTag / revalidatePath, or router.refresh() after a Server Action completes), not by disabling prefetch; otherwise everyone pays full price for one scenario you can invalidate explicitly.

staleTimes is the config knob behind those numbers: static controls how many seconds a static payload stays fresh in the client cache (default 300), dynamic how long a dynamic one does (default 0, so it revalidates the moment it is used). Raising dynamic makes back-navigation snappier and staleness longer — a deliberate per-application trade, never a substitute for invalidating after a mutation.

Verification is a production exercise, because dev skips prefetching entirely:

npm run build && npm run start
# DevTools → Network: scroll a link into view
# expect route chunks + an RSC request (headers include "RSC: 1") BEFORE any click
# then click: the transition completes with no document request

Three things go wrong in practice, in this order of likelihood: you are testing in dev (nothing prefetches); your "links" are plain <a> tags or a router from a library that calls router.push without prefetching (no background request ever appears); or the destination is dynamic with no loading.js, so it is skipped by design — add the boundary and watch the request appear.

One scheduling detail worth quoting in an interview: prefetching is not free even when it works, so the queue prefers links in the viewport, promotes links showing hover/touch intent, lets newer requests replace older ones, and drops work for links scrolled off-screen. It is a prioritized scheduler, not a firehose — which is also why a page of links does not flatten a slow connection.

Everything here hides latency by moving work earlier. The metrics that judge what's left — how long the hero takes to paint, whether content moves underneath the reader, how fast the page answers a tap — are in Auditing Core Web Vitals.

Quiz

1. In development you never see prefetch requests, but a production scroll past a nav bar fires route downloads. Which statement is true?

Question 1 options

2. Links to /blog/[slug] never prefetch, and clicking one leaves the old page on screen for a full server round trip. What is the best fix?

Question 2 options

3. Dashboard page views are double-counted: hovering any sidebar link logs a view. Root cause and fix?

Question 3 options

4. After publishing a post, navigating back to the list shows the old entry for minutes. What is the correct fix?

Question 4 options

Flashcards

Question

When and what does Next.js prefetch automatically?

Answer

Production only. As each <Link> enters the viewport (or on hover/touch intent) it fetches HTML on first contact, the route's JS chunks, and the RSC payload into an in-memory cache keyed by route segments. The scheduler orders work: viewport links first, intent (hover/touch) promoted, newer requests replace older ones, links scrolled off-screen are dropped.

Question

Static vs dynamic prefetch depth and client-cache TTLs?

Answer

Static route: the full route is prefetched and cached ~5 minutes (staleTimes.static default). Dynamic route with loading.js: only the layout down to the first loading boundary, client cache off by default (staleTimes.dynamic). Dynamic route without loading.js: skipped entirely, so the click pays a full server round trip. Sibling navigation reuses the cached parent layout and fetches only the changed leaf.

Question

The prefetch prop: default vs true vs false?

Answer

null/'auto' (default): static routes full, dynamic routes down to loading.js. true: full route for both kinds — and under Partial Prefetching it also resolves that link's URL data (searchParams/params) before the click; cost is a server invocation per visible link. false: never prefetch, on viewport entry or hover — the route is fetched on click instead.

Question

How do you prefetch on intent rather than on viewport?

Answer

Start the link at prefetch={false} and flip it to prefetch={null} (the default) on mouseenter/touch — null restores standard behavior, so nothing is reimplemented. For routes outside any Link (analytics, keyboard shortcuts) call router.prefetch(href) from useRouter. This targets likely navigations instead of paying for every visible row.

Question

Why do page views fire without visits, and what is the rule?

Answer

Layouts and pages render during prefetch, so any side effect in the component body (trackPageView, impression logging, writes) runs speculatively. The rule: server components stay pure — move side effects into a client useEffect (mount) or a Server Action triggered by a user gesture, which also keeps prefetch safe to run at any time.

Revision Notes

Key Takeaways

  • 1. Prefetching is production-only and viewport-triggered, and its queue prioritizes viewport links, then intent, then newer requests, while dropping off-screen work — a scheduler, not a firehose
  • 2. Static routes prefetch fully with a ~5-minute client cache; dynamic routes prefetch only to the first loading.js (skipped without one) with cache off by default — adding loading.tsx is usually the highest-leverage speedup
  • 3. The prefetch prop maps to cost models: default costs routes, true costs one server render per visible link, false costs the click — match the choice to the link's value
  • 4. Layouts run speculatively, so side effects in render fire without a visit; keep server components pure and track in useEffect or Server Actions
  • 5. Verify in production with Network (chunks plus the RSC request appear before any click), and fix post-mutation staleness at the cache layer rather than by switching prefetch off

Interview Tips

  • • "When does Next prefetch a route?" — production only, on viewport entry with hover/touch promotion; say what is fetched (chunks, RSC payload), where it lives (segment-keyed client cache), and the scheduler's four priorities
  • • "Static vs dynamic prefetch?" — full route vs down to the first loading boundary; skipped entirely without loading.js; TTLs 5 minutes vs off; sibling navigation reuses the parent layout
  • • "A page has 500 links — what do you do?" — prefetch={false} on the list plus the hover-intent pattern or router.prefetch; contrast with prefetch={true}, which would cost one server render per visible link
  • • "How do you debug navigation that isn't instant?" — confirm a production build, check Network for prefetch requests before the click, confirm the destination is a next/link anchor and dynamic routes have a loading boundary, then check whether data is stale (invalidate) rather than missing (prefetch)

Cheat Sheet

Router Prefetching Behavior

Model: production only · triggered on viewport entry (hover/touch promoted) · stores chunks + RSC payload in a segment-keyed client cache · scheduler: viewport → intent → newer wins → off-screen dropped.

Destination Prefetched Client cache TTL
Static full route 5 min (staleTimes.static)
Dynamic + loading.js layout → first loading boundary off (staleTimes.dynamic)
Dynamic, no loading.js skipped —

prefetch prop: null/"auto" default · true = full route for both (+ URL-data under Partial Prefetching; one server render per visible link) · false = never, not even on hover.

Intent-based prefetching:

const [active, setActive] = useState(false)
<Link href={href} prefetch={active ? null : false} onMouseEnter={() => setActive(true)}>{children}</Link>
// or, outside Link:
onMouseEnter={() => router.prefetch('/pricing')}

Purity rule:

// ✗ runs during prefetch
export default function Layout({ children }) { trackPageView(); return <div>{children}</div> }
// ✓ mount-time
'use client'; useEffect(() => { trackPageView() }, [])

Debug "my navigation isn't instant": npm run build && npm start (dev never prefetches) → Network: chunks + RSC: 1 request before click → links must be next/link → dynamic route needs loading.tsx → stale data is fixed with revalidateTag/revalidatePath/router.refresh(), never by disabling prefetch.

Continue Learning