cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

Prefetching and preloading data

Starting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.

The wait after the click

Picture a shop's product list. A user scans the grid, moves the mouse onto a pair of running shoes, and clicks. The router swaps the URL, renders a skeleton, and only then asks the server for the product. The API takes 250 ms on a good day.

So the user stares at grey boxes for a quarter of a second, and longer on a phone.

That wait is not caused by a slow server. It is caused by when the request starts. The app had a strong hint about the next screen long before the click: the pointer sat on the card for a few hundred milliseconds. The app ignored the hint and waited for certainty.

Prefetching means starting the work for a likely next screen before the user asks for it: its data, its JavaScript chunk, sometimes the whole page. When the click comes, part or all of the work is already done, so the screen appears sooner or at once.

Two timelines around one click on a product link, from 300 milliseconds before the click to 300 after. The pointer reaches the link 300 milliseconds before the click. Without prefetch, the 250 millisecond fetch starts at the click, so the user waits 250 milliseconds. With a hover prefetch after an 80 millisecond delay, the fetch starts 220 milliseconds before the click and ends 30 milliseconds after it, so the user waits only 30 milliseconds.Without prefetchfetchuser waits 250 msPrefetch on hover, after an 80 ms delayfetchwaits 30 mshoverclick+250 ms
The fetch takes 250 ms in both rows. Prefetching does not make it faster; it starts it during time the user spends deciding.

Two different ideas with similar names

This lesson uses two words that people often mix up. Keep them apart from the start, because the browser treats them very differently.

  • Preloading fetches something the current page will certainly need, but that the browser would find late: a hero image named in CSS, a font, the data a route needs as soon as it renders. It is about this page, so it is not a bet.
  • Prefetching fetches something a future page might need: the next route's data or code. It is a bet. If the user goes somewhere else, the bytes were wasted.

Both move a request earlier in time. The difference is certainty. Preloading is reordering work you must do anyway. Prefetching is doing work you might not need, so it has a cost model: how likely the bet is to pay off, and what it costs when it does not.

What can be fetched ahead

A client-side navigation in a modern single-page app (SPA) usually needs three kinds of things: code, data, and media. A multi-page app (MPA), where each click loads a new HTML document, needs a fourth: the document itself. Prefetching can target any of them.

WhatExampleTypical sizeWho usually fetches it ahead
Route codeThe ProductPage JavaScript chunk20 to 150 KBThe framework router (Link prefetch)
Route dataGET /api/products/42 as JSON2 to 50 KBYour data layer or the route loader
MediaThe product's main image50 to 300 KBRarely; usually loaded by the page itself
Whole documentThe HTML of /products/42 for a multi-page app20 to 100 KB plus subresourcesThe browser, via Speculation Rules

The lesson on loading strategies already covers the resource hints that load files for the current page. The code-splitting lesson covers how routes become chunks. This lesson focuses on the next step: getting the next screen's data and code moving early, deciding when to start, and making sure the fetched data is actually used.

Check your understanding · Exercise 1

Which of these are prefetching (a bet on a future screen) rather than preloading (work the current page will surely do)? Select all that apply.

The mental model: a head start bought with a guess

Think of every navigation as a race between two clocks. One clock is the user: from the first sign of interest to the click. The other is the network: from the first byte requested to the last byte parsed.

Without prefetching, the network clock only starts when the user clock stops. Prefetching starts the network clock early.

The time a user waits after the click is simple to work out. Call the time from the signal to the click the lead time, and the time the fetch needs the fetch time. The wait after the click is the fetch time minus the lead time, or zero if the lead time is longer.

text
wait after click = max(0, fetch time - lead time)

fetch 250 ms, lead 0 ms   (no prefetch)      -> wait 250 ms
fetch 250 ms, lead 90 ms  (on pointerdown)   -> wait 160 ms
fetch 250 ms, lead 300 ms (on hover)         -> wait   0 ms
fetch 900 ms, lead 300 ms (slow API, hover)  -> wait 600 ms
The head start never makes a request faster. It only moves its start earlier.

Two things follow from this. First, a short lead time is enough for fast APIs: if the data takes 120 ms, a 90 ms head start removes most of the wait. Second, for slow APIs no trigger near the click is enough, so you need an earlier signal (the link scrolling into view) or a faster backend.

How early the signals come

Users leak their intent in a fixed order. Each signal comes later than the one before, and each is more likely to end in a click. The numbers below are rough figures for a desktop mouse; your own logs will differ, so treat them as a starting point.

  • In view: the link scrolls into the viewport. Seconds before a click, if a click comes at all. Most visible links are never clicked.
  • Hover: the pointer rests on the link. Often 100 to 300 ms or more before the click on desktop: instant.page measured that once the pointer has rested on a link for 65 ms, a click follows about half the time, with over 300 ms to go on average. A pointer that only crosses the link on its way elsewhere also fires pointerenter, so a short delay filters those out.
  • Focus: keyboard focus lands on the link. Similar to hover for keyboard users, who tab through links before pressing Enter.
  • Press: pointerdown fires when the button or finger goes down. The click event fires when it comes back up, usually 60 to 120 ms later (instant.page measured about 80 ms for a mouse and 90 ms for touch). Almost always followed by a click.
Four signals in time order, left to right: the link comes into view, the pointer hovers or keyboard focus lands on it, the button is pressed, and the click fires. Head start before the click: seconds, 100 to 300 milliseconds, 60 to 120 milliseconds, and zero. Chance that a click follows: low, medium, very high, and certain.In viewsecondslowHover, focus100 to 300 msmediumPress60 to 120 msvery highClick0 mscertainhead start before the clickchance that a click follows
Each later signal gives less time and more certainty. Choosing a trigger is choosing a point on this line.

This is the central trade-off of the whole topic: earlier signals buy more time but guess wrong more often. Every design decision below is a choice of where on this line to start, and for which links.

An everyday comparison

A good waiter watches the table. When you close the menu, they walk over before you wave. When the main course is nearly done, they bring the dessert menu. Those guesses are cheap and usually right.

But they do not cook all twelve desserts just in case, because eleven would go in the bin. Prefetching on hover is closing the menu. Prefetching every link in view is cooking every dessert.

Where prefetched data lives

A prefetch is only useful if the real navigation finds what it fetched. So the second half of the mental model is the cache the prefetch writes into. There are several, at different layers, and a prefetch into the wrong one is wasted.

Two layers of caches a prefetch can write into. In your JavaScript, used by client-side navigations: the data cache, keyed by your query keys and read by the page, written by prefetchQuery; and the router cache, holding loader results by URL, written by loaders and Link prefetch. In the browser, used by real document loads: the HTTP cache, keyed by URL and Vary headers, written by link rel=prefetch; and the speculation store, holding whole pages in Chromium, written by speculation rules.Your JavaScript: client-side navigationsThe browser: document loadsData cacheyour keys, read by the pageprefetchQueryRouter cacheloader results, by URLloader, LinkHTTP cacheby URL and Vary headersrel=prefetchSpeculation storewhole pages, Chromium onlyspeculationrules
A prefetch helps only if the navigation reads the same layer it wrote. In an SPA, data prefetches belong in the top zone; rel=prefetch and speculation rules serve the browser's own loads.
  • Your data cache (TanStack Query, SWR, Apollo, a router's loader cache). Keyed by your own keys, such as ["product", "42"]. The page reads from it directly. This is where data prefetches should go in an SPA.
  • The router's cache (the Next.js App Router's client cache, TanStack Router's loader cache). Stores the result of a route's loader or server render, keyed by URL.
  • The HTTP cache. Keyed by URL and some headers. A link rel=prefetch writes here, and the next request for the same URL can read from it if the response is cacheable.
  • The browser's speculation store. Pages fetched or prerendered by Speculation Rules live in memory for a short time and are used only by a navigation to that exact URL.

Think first

Your product API answers in 80 ms at the median and 700 ms at the 95th percentile. Which trigger would you pick, and what does it do for the slow tail?

Triggers: when to start the fetch

A trigger is the event that starts a prefetch. Picking one is picking a point on the intent line from the last chapter. This chapter walks through each trigger, how to build it, and the mistake people make with it.

Hover and focus, with a short delay

Hover is the default for desktop because it is early enough and usually right. Listen for pointerenter, wait a short time, and start the fetch only if the pointer is still there. Cancel the timer on pointerleave. Do the same for focus and blur, so keyboard users get the same head start.

The delay matters. A pointer crossing a dense grid on its way to the search box enters ten cards in half a second. With no delay, that is ten wasted requests.

Libraries use a short delay: TanStack Router waits 50 ms by default, and the instant.page script waits 65 ms. That is long enough to skip a pointer in transit and short enough to keep most of the head start. This lesson uses 80 ms in its examples; anything from 50 to 100 ms is reasonable.

tsx
function usePrefetchOnIntent(prefetch: () => void, delayMs = 80) {
  const timer = useRef(0);
  const start = () => {
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(prefetch, delayMs);
  };
  const cancel = () => window.clearTimeout(timer.current);
  return {
    onPointerEnter: start,
    onPointerLeave: cancel,
    onFocus: start,
    onBlur: cancel,
    // Press is a near-certain click: skip the delay.
    onPointerDown: prefetch,
  };
}
Spread the returned handlers on a link. The prefetch function must be idempotent: the data layer should ignore a second call while the first is in flight or fresh.

Press: pointerdown and touch

Touch screens have no hover. The first signal of a tap is pointerdown (or touchstart), and the click follows when the finger lifts, usually 60 to 120 ms later. That is a small head start, but it is almost never wrong, so it is safe to use everywhere.

A mistake here is to start navigation itself on pointerdown. Some sites do it to feel faster, but it breaks a user who presses, changes their mind, and drags away before lifting. Start the fetch on press; navigate on click.

Viewport: links as they appear

An IntersectionObserver tells you when a link enters the viewport. This is the earliest trigger that is still tied to a specific link, and it is the only early one on touch screens. It is also the most wasteful: a feed shows dozens of links and the user clicks one or none.

Use viewport prefetching for small, cheap things: a route's JavaScript chunk that many links share, or a handful of primary links. Next.js does this for Link in production builds, and for dynamic routes it fetches only down to the route's loading state. For per-item data, viewport prefetch usually costs more than it saves.

Prediction and idle time

Sometimes the app knows the next step without any pointer signal. After the cart comes checkout. After page 2 of results comes page 3. After an email is opened, the next email in the list is likely.

Fetch these when the browser has nothing else to do, using requestIdleCallback (with a setTimeout fallback, because not every browser supports it).

Some teams go further and predict from analytics: if 60 percent of users on the pricing page go to sign-up next, prefetch sign-up. This works, but measure it. A model that is right 60 percent of the time still wastes 40 percent of its fetches.

Decision Which trigger should a product grid use to prefetch each product's data?

Viewport

Observe every card with IntersectionObserver and prefetch its data when it appears.

Pros

  • Earliest head start; works on touch screens
  • Hides even slow APIs

Cons

  • Fetches 20 to 40 items per screen for one click
  • Bursty load on the API as users scroll

Hover and focus, with an 80 ms delay

Start on pointerenter or focus after a short delay; cancel on leave.

Pros

  • Usually 100 to 300 ms of head start
  • Most prefetches are used

Cons

  • Does nothing on touch screens
  • Not enough for very slow APIs

Press only

Start on pointerdown; the click follows when the button lifts.

Pros

  • Almost zero waste
  • Same code on mouse and touch

Cons

  • Only about 60 to 120 ms of head start

What we pick: Combine hover, focus, and press: hover and focus give desktop users the big head start, press covers touch, and almost nothing is wasted. Reserve viewport prefetch for shared code chunks or a few top links.

Timeline around the click. Without prefetch the 250 millisecond fetch starts at the click and the user waits 250 milliseconds. With Hover, 80 ms delay, the fetch starts 220 milliseconds before the click and the user waits 30 milliseconds.no prefetchprefetchclick+300 ms
The top bar never moves: it starts at the click. The bottom bar starts at the trigger. The part of it to the right of the click line is what the user still waits for.

Head start: 220 ms. Wait after the click: 30 ms instead of 250 ms, 220 ms saved. For a slow API no near-click trigger is enough; try the viewport trigger to see why some teams pay for it.

Check your understanding · Exercise 2

A team adds hover prefetch to a dense list and sees API traffic triple. Which changes would cut the waste without losing most of the speed-up? Select all that apply.

Route-level data loading: kill the waterfall first

Before guessing about the next page, fix the current one. In many SPAs a click triggers three steps in a row: download the route's JavaScript chunk, render the component, and only then start the data request inside a useEffect. Each step waits for the one before it. That chain is a waterfall, and it often costs more than any prefetch can win back.

Worked example: the chunk takes 180 ms, rendering the skeleton takes 20 ms, and the API takes 250 ms. In a waterfall the data arrives at 180 + 20 + 250 = 450 ms. If the data request starts at the same moment as the chunk download, the page is ready at about max(180 + 20, 250) = 250 ms. Nothing got faster; the two requests just stopped waiting for each other.

Two timelines from 0 to 500 milliseconds after a click. Fetching in an effect: the route chunk downloads from 0 to 180 milliseconds, the component mounts until 200, and only then the 250 millisecond API call runs, so the data arrives at 450. With a route loader: the chunk and the API call both start at 0; the chunk and mount finish at 200 and the data at 250, so the page is ready at 250 milliseconds.Fetch in useEffectcode, mountdataready: 450 msRoute loadercode, mountdataready: 250 ms0250500 ms
Same chunk, same API, same network. The loader only changes when the data request starts, and that saves 200 ms before any prefetching.

Loaders: data declared on the route

The fix is to let the router start the data request, not the component. A loader is a function attached to a route that returns the data the route needs. Because the router knows the loader before it has the route's code, it can start both at once when navigation begins.

  • React Router (and Remix): a route has a loader and can have a lazy function that imports its code. When the loader is defined on the route itself, the router calls both in parallel. It passes request.signal, so a navigation that is replaced by another one aborts its fetch.
  • TanStack Router: routes have a loader with a built-in cache, and defaultPreload: "intent" runs a route's loader when its Link is hovered or focused, or touched. Often paired with TanStack Query so the loader fills the query cache.
  • Next.js App Router: data is fetched in Server Components on the server, so the browser receives rendered output rather than calling the API itself. Link prefetches routes as they enter the viewport in production, up to the nearest loading.js boundary for dynamic routes.

Once data lives on the route, prefetching the next page becomes almost free to add: run the next route's loader early. Prefetching and loaders are the same machinery, started at different times.

ts
const productQuery = (id: string) =>
  queryOptions({
    queryKey: ["product", id],
    queryFn: () => getJson(`/api/products/${id}`),
    staleTime: 30_000,
  });

export const productRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/products/$id",
  // Runs on navigation, and on hover when preload is "intent".
  loader: ({ context, params }) =>
    context.queryClient.ensureQueryData(productQuery(params.id)),
  component: ProductPage,
});

function ProductPage() {
  const { id } = productRoute.useParams();
  const { data } = useSuspenseQuery(productQuery(id));
  return <ProductView product={data} />;
}
One queryOptions object is shared by the loader and the component, so the key and the fetcher can never drift apart. ensureQueryData returns cached data if it exists and fetches only if it does not.

prefetchQuery, ensureQueryData, and friends

Data libraries expose a few functions for this. They look alike but behave differently, and picking the wrong one causes either duplicate requests or swallowed errors.

TanStack Query v5 names. Both prefetchQuery and fetchQuery reuse an in-flight request for the same key instead of starting a second one.
FunctionReturnsIf cached and freshOn errorUse it for
queryClient.prefetchQueryA promise of nothingDoes nothingSwallowedHover and idle prefetches: fire and forget
queryClient.ensureQueryDataThe dataReturns it without fetchingThrowsRoute loaders that need the data
queryClient.fetchQueryThe dataReturns it if within staleTime, else fetchesThrowsImperative reads outside React
SWR preload(key, fetcher)A promise of the dataNot checked; a repeat call shares the pending requestRejectsPrefetching in SWR apps

That last detail matters. If the user hovers, the prefetch starts, and they click 50 ms later, the page's query finds a request already in flight for the same key and waits for it. It does not start a second request. This is request deduplication working with prefetching, and it is why the key must match exactly.

Check your understanding · Exercise 3

A route's chunk takes 300 ms and its API takes 200 ms. Today the component fetches in useEffect. What happens when you move the fetch to a route loader that runs in parallel with the chunk? Select all that apply.

The handoff: making sure the prefetch is used

A prefetch has two halves. The first half fetches and stores. The second half, the handoff, is the moment the real navigation looks for the data and finds it. Teams test the first half (the request shows in the network panel) and forget the second.

If the handoff fails, the prefetch is pure cost: the user pays for the bytes twice and still waits.

A sequence diagram with four lifelines: the link, the page, the query cache, and the API. On hover, after the delay, the link calls prefetch for the key product 42 on the query cache. The cache sends GET /api/products/42 to the API, which answers 200 with 8 KB. 220 milliseconds later the user clicks, and the router renders the page. The page reads the key product 42 from the cache and gets the data at once, with no second request.LinkPageQuery cacheAPIprefetch [product, 42]GET /api/products/42200, 8 KBclickread [product, 42]data, no request
The handoff works because the link and the page use the same key. Change one character of the key and the page sends its own request.

The life of a cache entry

To reason about handoffs, follow one entry in a data cache like TanStack Query. It moves through a few states, and two timers decide when it moves: staleTime, how long data counts as fresh, and gcTime, how long an entry nobody is using stays in memory before it is thrown away.

A state machine for one query cache entry. Empty, with nothing cached, moves to Fetching when a prefetch or a read starts one request. Fetching moves to Fresh when the 200 response arrives; a read while fresh makes no request. Fresh moves to Stale when staleTime passes; a stale entry is shown but refetched. When a page mounts, Stale moves back to Fetching. If no page uses a stale entry for gcTime, it moves to Removed.Emptynothing cachedFetchingone requestFreshread: no requestStaleshown, refetchedRemovedafter gcTimefetch200staleTimepage mountsunused for gcTime
Two timers drive it: staleTime decides when a read triggers a refetch, and gcTime decides when an unused entry is thrown away. A prefetch is used only if the page reads it while it is Fetching or Fresh.
  • Fetching: a request is in flight. A page that asks for the same key now joins this request instead of starting a new one.
  • Fresh: the data arrived less than staleTime ago. A page that mounts now uses it and makes no request.
  • Stale: older than staleTime. A page that mounts now shows it at once but also refetches in the background.
  • Removed: no component used the entry for gcTime (5 minutes by default in TanStack Query). The next read starts from nothing.

Five ways the handoff breaks

  1. Key mismatch. The link prefetches ["product", 42] with a number; the page reads ["product", "42"] from the URL as a string. Two different keys, two requests. Share one query definition to make this impossible.
  2. staleTime of zero. TanStack Query's default staleTime is 0, so prefetched data is stale the instant it lands. The page shows it, then refetches anyway. The user sees content at once, which is good, but every navigation still sends a second request. Set a staleTime that matches how fast the data changes, even 10 to 30 seconds.
  3. Different fetch options. For HTTP cache prefetches (link rel=prefetch, or a plain fetch to warm the cache), the later request must match: same URL, same credentials mode, same Vary-relevant headers. A prefetch without cookies does not satisfy a request with them.
  4. Uncacheable responses. If the API answers with Cache-Control: no-store, an HTTP cache prefetch may not be reusable. A JavaScript data cache does not care about these headers, which is one reason data prefetching usually lives there.
  5. Expired before use. A link hovered five minutes ago may have been garbage collected, or its data may be stale. That is usually fine: it simply behaves like no prefetch.
ts
// In development, count handoffs to catch silent misses.
const started = new Set<string>();
export function prefetchProduct(id: string) {
  started.add(id);
  return queryClient.prefetchQuery(productQuery(id));
}
export function reportHandoff(id: string) {
  const state = queryClient.getQueryState(productQuery(id).queryKey);
  const hit = state?.status === "success" || state?.fetchStatus === "fetching";
  analytics.track("prefetch_handoff", { prefetched: started.has(id), hit });
}
Call reportHandoff at the start of navigation, before the page mounts. A prefetched id that is not a hit means the handoff is broken.

Use what you already have

Often the list already holds part of the next screen. A product card knows the name, price, and thumbnail. Seed the detail page with that while the full product loads: TanStack Query's placeholderData (or initialData with an old initialDataUpdatedAt, so it is refetched) shows the card's fields at once. The click then paints a real header instead of a skeleton, even when no prefetch ran.

Check your understanding · Exercise 4

The network panel shows GET /api/products/42 twice: once on hover, once right after the click. Which causes fit? Select all that apply.

Preload, prefetch, preconnect: the browser's own hints

Everything so far ran in your JavaScript. HTML also has link hints that ask the browser to do some of this work itself, often earlier than your code could, because the browser sees them while parsing the HTML. The loading strategies lesson covers them for page assets like fonts and images. Here we look at them through one question: how do they help with data and the next route?

A comparison matrix of six browser hints. preconnect: for any page, opens a connection, tiny cost if unused. preload: this page, one file, costs bytes during page load. modulepreload: this page, a JavaScript module, bytes during page load. prefetch: next page, one file, idle bytes. Speculation rules prefetch: next page, the HTML document, one request. Speculation rules prerender: next page, the whole page, costs CPU and memory as well as bytes.hintscopefetchescost if unusedpreconnectany pagea connectiontinypreloadthis pageone filebytes at loadmodulepreloadthis pagea JS modulebytes at loadprefetchnext pageone fileidle bytesspec. prefetchnext pagethe HTMLone requestspec. prerendernext pagewhole pageCPU and memory
Read the rows top to bottom as rising cost. Preconnect is almost free; a wasted prerender costs a full page load.

preload: the current route's data, found early

In a client-rendered app, the route's API call starts only after the main bundle downloads and runs, maybe 600 ms into the page load. But the server that sends the HTML already knows the URL: it is /products/42, so the data is /api/products/42. A preload with as="fetch" starts that request while the bundle is still downloading.

html
<!-- Sent in the HTML for /products/42 -->
<link rel="preconnect" href="https://api.shop.example" crossorigin>
<link rel="preload" href="https://api.shop.example/products/42"
      as="fetch" crossorigin="anonymous">
<link rel="modulepreload" href="/assets/product-page.js">
The later fetch must match the preload's URL and credentials mode, or the browser downloads it again and warns in the console that the preload was unused.

The trap is matching. A preload is only reused by a request with the same URL, the same destination, and the same CORS and credentials mode. crossorigin="anonymous" matches a fetch that sends no cookies to another origin; a fetch with credentials: "include" needs crossorigin="use-credentials". Get it wrong and you pay twice, at the busiest moment of page load.

prefetch: a file for the next navigation

link rel="prefetch" asks the browser to fetch a URL at the lowest priority, when it is idle, and keep it in the HTTP cache for a later navigation. It suits files that are the same for every user: the next route's JavaScript chunk, or a static JSON file. Bundlers emit these for you: webpack's webpackPrefetch magic comment adds one for a lazy chunk.

It suits per-user API data less well. The response often has Cache-Control: private, no-cache or no-store, the request must match exactly to be reused, and Safari does not support rel="prefetch" by default. For data in an SPA, a JavaScript prefetch into your data cache is more reliable.

preconnect: the cheapest bet

A preconnect fetches no data at all. It opens the connection to another origin early, which can save 200 to 300 ms on the first request there over a 100 ms mobile round trip. If your API lives on another origin, preconnect to it from the first page, with crossorigin because API calls are CORS requests.

It costs little: a bit of CPU and a few round trips. But Chrome closes a connection that stays unused for about 10 seconds, so keep it to the two to four origins you will certainly call soon.

Check your understanding · Exercise 5

Which of these uses of a browser hint fit the job? Select all that apply.

Speculation Rules: prefetch or prerender whole pages

Everything so far helps an SPA, where JavaScript handles the click. An MPA, where every click loads a new document, needs the browser itself to get ahead. The Speculation Rules API does that. You describe which links are likely, and the browser decides when to fetch or even fully render them.

html
<script type="speculationrules">
{
  "prerender": [{
    "where": { "and": [
      { "href_matches": "/products/*" },
      { "not": { "selector_matches": ".no-prerender" } }
    ]},
    "eagerness": "moderate"
  }],
  "prefetch": [{
    "urls": ["/cart"],
    "eagerness": "eager"
  }]
}
</script>
A document rule (where) matches links on the page; a list rule (urls) names URLs directly. Browsers without support ignore the script.

Prefetch versus prerender

  • Prefetch downloads only the next page's HTML document and keeps it in memory. On the click, the browser skips the wait for the document, but still has to fetch its CSS, scripts, and images, then parse and render. Cheap: one request.
  • Prerender loads the whole next page in a hidden renderer: the document, every subresource, and all of its JavaScript, which runs. On the click the browser swaps the hidden page in, often in tens of milliseconds. Expensive: a full page load's bytes, CPU, and memory.

Prerender only makes sense when the click is very likely. Prefetch is a safer default for broader rules. A common setup is to prefetch on moderate and prerender only a few top links, or none.

Eagerness: the trigger, chosen by the browser

The eagerness setting maps to the triggers from earlier chapters. You pick the level; the browser picks the exact signals, which can change between versions and between desktop and mobile.

Chrome's behavior as of 2026. Before Chrome 143, eager behaved like immediate, and before 2025 moderate on mobile waited for a press.
EagernessRoughly when it startsDefault for
immediateAs soon as the rule is seenList rules (urls)
eagerDesktop: the pointer rests on a link for about 10 ms. Mobile: a link has been in view for about 50 msNone
moderateDesktop: the pointer rests on a link for 200 ms, or on press. Mobile: viewport heuristicsNone
conservativeOn pointer down or touch startDocument rules (where)

Chrome also caps how many speculations it keeps. immediate rules can hold up to 50 prefetches and 10 prerenders. For eager, moderate, and conservative, only the 2 most recent prefetches and 2 most recent prerenders are kept; a third cancels the oldest. So hovering across a grid does not stack up dozens of hidden pages.

A flowchart for one link matched by a speculation rule. If the browser does not support the API, the script is ignored. If data saving is on or memory is low, the speculation is skipped. Otherwise the browser waits for the eagerness trigger, such as a 200 millisecond hover for moderate on desktop, then prefetches or prerenders. If the user clicks that exact URL, the page is shown at once; if not, the speculation is dropped and its bytes are wasted.Link matches a ruleBrowser supports it?noNo: script ignoredData saving, low memory?yesYes: skippedEagerness trigger firesUser clicks that URL?noNo: dropped, wastedyesPage shown at once
You write the rule; the browser makes every other decision, including skipping it entirely. Treat speculation as a bonus, never as the only path to a fast page.

What to watch for

  • Support. Chromium-based browsers (Chrome, Edge, Opera, Samsung Internet) support Speculation Rules. Safari has an implementation that is off by default, and Firefox does not ship it yet. Browsers that do not act on the script ignore it, so it is safe progressive enhancement, but do not count on it for every user.
  • Side effects. A prerendered page runs its scripts before anyone sees it. Analytics that count a page view on load would count pages never shown. Check document.prerendering and wait for the prerenderingchange event. Google Analytics already handles this; check any other analytics script you use.
  • Server visibility. Speculative requests carry a Sec-Purpose: prefetch header (with ;prerender for prerenders). Servers can use it to skip side effects or log speculation separately, and can answer with an error to refuse.
  • State that changes. A page prerendered before the user added an item to the cart shows the old cart count. Pages that depend on recent client state should not be prerendered, or should refresh that state on prerenderingchange.
  • Browser conditions. Chrome skips speculation when the user has turned on data saving, when Energy Saver is on and the battery is low, when memory is low, in background tabs, or when the user turned off page preloading in settings.

Think first

Your app is an SPA built with a client router. Will adding a Speculation Rules script speed up clicks between routes?

The cost of guessing wrong

Every prefetch the user never uses is waste, and someone pays for it. The user pays in mobile data and battery. Your servers pay in requests per second. The current page pays too, because a prefetch competes with the requests the user is waiting for right now.

A good prefetch design starts from these costs, not from the speed-up.

The waste, in numbers

Take a product grid with 40 cards on screen. Each product's JSON is 8 KB, and the user clicks one product. Compare what each trigger fetches for that single click.

The hover numbers are an assumption for illustration; measure your own hover-to-click ratio.
TriggerFetchedUsedWastedHit rate
Viewport, every card40 x 8 KB = 320 KB8 KB312 KB1 in 40 (2.5%)
Hover, 80 ms delay (3 cards hovered)3 x 8 KB = 24 KB8 KB16 KB1 in 3 (33%)
Press only8 KB8 KB0 KBabout 100%
A bar chart, to scale, of bytes prefetched for one click on a grid of 40 products at 8 KB each. Viewport prefetch fetches all 40 cards: 320 KB, of which 312 KB are wasted. Hover with a delay fetches 3 cards: 24 KB, 16 KB wasted. Press only fetches the clicked card: 8 KB, nothing wasted. The used part of each bar is the same 8 KB.bytes per click, to scale: used, then wastedviewport: all 40 cards320 KB, 312 wastedhover with delay: 3 cards24 KB, 16 wastedpress only: 1 card8 KB, 0 wasted
The useful part of every bar is the same 8 KB. Everything to its right is the price of starting earlier.

Now multiply by traffic. With 1 million grid views a day, viewport prefetching sends 40 million product requests a day for about 1 million real visits: an average of roughly 450 wasted requests per second, and several times that at peak, all arriving in bursts as people scroll. If those responses are not cached at a CDN, every one reaches your origin.

Measure it: hit rate and wasted bytes

Track two numbers for every prefetch source. The hit rate is prefetches used divided by prefetches started, where used means the navigation found the data in the cache (the handoff from the earlier chapter). The wasted bytes are the bytes of prefetches that were never used. Also track the gain: the time from click to content, with and without the prefetch, split by trigger.

There is no universal good hit rate. A 33 percent hit rate on 8 KB JSON for a 250 ms gain is cheap. A 33 percent hit rate on full prerenders of heavy pages is expensive. Decide with both columns: bytes and server cost per prefetch, against time saved per hit.

The cards on screen in the last visit, 40 of them: no visit yet. Totals over 0 visits: 0 KB prefetched, 0 KB used, 0 KB wasted, hit rate 0 percent.12345678910111213141516171819202122232425262728293031323334353637383940no visit yethit rate 0%saves 0 ms a clickprefetched 0 KB, used 0 KB, wasted 0 KBat 1 million visits: 0 prefetch requests
Green outline: prefetched and clicked. Red: prefetched and never used. Warm: clicked but not prefetched. The bar shows all bytes prefetched so far, used part first.

Simulate a few visits. Each visit, the user looks at the grid and clicks one card.

Guards every prefetcher needs

  • Respect data saving. Chromium browsers send a Save-Data: on request header when the user asks to save data, and expose navigator.connection.saveData and effectiveType. Skip speculative work when data saving is on or the connection is 2g. Safari and Firefox expose neither, so a missing value means unknown, not fast.
  • Only safe requests. Prefetch GET requests that have no side effects. Never prefetch a URL that logs out, adds to a cart, marks a message read, or counts a view.
  • Lower the priority. Mark prefetch requests as low priority, for example fetch(url, { priority: "low" }), so they never slow the page the user is looking at. Chrome 101, Safari 17.2, and Firefox 132 and later support it; older browsers ignore it.
  • Cap the concurrency. Allow a few prefetches in flight at once and drop the oldest when a new one starts. A user sweeping across a menu should not queue twenty requests.
  • Cache at the edge. Make prefetched responses cacheable at the CDN when they are public, so a burst of prefetches hits the edge, not your origin.
  • Let the server say no. Tag prefetches with a header such as Purpose: prefetch (or read Sec-Purpose from Speculation Rules), so the backend can shed them first under load. On a cross-origin API, a custom header adds a CORS preflight request, so a query parameter can be cheaper.
ts
export function canPrefetch(): boolean {
  const c = (navigator as Navigator & {
    connection?: { saveData?: boolean; effectiveType?: string };
  }).connection;
  if (c?.saveData) return false;
  if (c?.effectiveType && /(^|-)2g$/.test(c.effectiveType)) return false;
  if (document.visibilityState !== "visible") return false;
  return true;
}
One gate for every prefetch source in the app: hover, viewport, idle, and prediction.

Check your understanding · Exercise 6

After shipping viewport prefetch on a feed, API traffic rose 12 times and median navigation time dropped by 180 ms. Which next steps are sound? Select all that apply.

In production: edge cases and real frameworks

Prefetching looks simple in a demo. In production it meets mutations, logins, slow phones, and caches that live longer than the user expects. This chapter covers what breaks and how the popular frameworks set their defaults.

What breaks, and how you notice

ProblemHow you noticeFix
Stale data after a writeUser edits a product, hovers back to it, sees the old priceInvalidate or update the affected keys after every mutation; prefetch respects that
Data from the wrong userAfter logout and login, a prefetched page shows the old accountClear the data cache on logout and on account switch
Duplicate requestsSame URL twice in the network panelShare one query definition; set a staleTime
Prefetch slows the current pageWorse LCP or INP after enabling prefetchLow priority, start after the page is idle, cap concurrency
Origin overloadTraffic spikes that track scrolling, not visitsHover instead of viewport, CDN caching, shed Purpose: prefetch first
Inflated analyticsPage views rise but engagement does notDo not count prefetch or prerender as views; check Sec-Purpose and document.prerendering

One more subtle case: an abandoned prefetch. The user hovers a link, the request starts, and they leave. Let it finish rather than aborting it, if it is cheap, because a later hover will find it fresh. But abort loader fetches when a navigation is replaced by another one: React Router passes request.signal for exactly this.

How frameworks set it up

  • Next.js App Router: Link prefetches routes as they enter the viewport, in production builds only. For static routes it fetches the whole payload; for dynamic routes it fetches down to the nearest loading.js, so the click shows the loading state instantly while the rest streams. prefetch={false} turns it off for a link, and router.prefetch(href) prefetches by hand.
  • React Router (framework mode) and Remix: Link takes prefetch="none", "intent", "render", or "viewport". With intent, hovering or focusing the link fetches the next route's modules and loader data.
  • TanStack Router: defaultPreload: "intent" runs a route's loader and loads its code on hover or focus after defaultPreloadDelay (50 ms by default), and at once on touch start. Preloaded loader data counts as fresh for 30 seconds by default, so the click can reuse it.
  • Small scripts for MPAs: instant.page prefetches a page on hover after 65 ms, and Google's quicklink prefetches links in the viewport while the browser is idle, skipping slow connections and data saving. Today, Speculation Rules can do the same jobs natively in Chromium.

Notice the pattern. Frameworks prefetch code generously (in the viewport) because chunks are shared, cacheable, and the same for everyone. They mostly prefetch data on intent, because data is per item, often per user, and goes stale. Next.js is the partial exception: it prefetches the full payload of static routes in the viewport, because that payload is the same for everyone.

A decision tree for one link. If the request is not a safe GET without side effects, never prefetch it. If the user has data saving on or a 2g connection, skip. If the target is an obvious next step, prefetch when the browser is idle. If it is a shared code chunk, prefetch it when the link is in the viewport. Otherwise, prefetch on hover, focus, and press.Safe GET, no side effect?noNo: never prefetchData saving or 2g?yesYes: skipObvious next step?yesYes: when idleShared code chunk?yesYes: in viewportHover, focus, press
Most links end at the bottom box. Earlier triggers are for the few cases where the bet is very likely, or very cheap.

Decision Where should prefetch logic live in a large SPA?

In each component

Every card or link calls prefetchQuery with its own key and fetcher.

Pros

  • Quick to add to one screen
  • No shared setup

Cons

  • Keys drift between link and page
  • Guards like Save-Data get forgotten
  • Hard to measure

In the router

Routes declare loaders; the router's Link runs them on intent.

Pros

  • One definition per route, used by navigation and prefetch
  • Code and data fetched together

Cons

  • Ties data loading to the router
  • Needs a router that supports loaders

In a small prefetch service

A module with prefetch(route, params) that applies guards, priority, caps, and metrics, then calls the router or data cache.

Pros

  • One place for guards and measurement
  • Works with any trigger, including predictions

Cons

  • Another layer to maintain

What we pick: Use the router's loaders as the single source of truth for what a route needs, and put guards, caps, and metrics in one shared prefetch function that every trigger calls. Avoid ad hoc prefetches in components.

Check your understanding · Exercise 7

Which of these are real risks of prefetching in production? Select all that apply.

Using it in a system design interview

Prefetching comes up in almost every frontend design question that has navigation: a news feed, an e-commerce site, an email client, a photo gallery. Bring it up after you have a working design, when you discuss performance, and keep it tied to a specific screen and a specific number.

What to say

  1. Name the slow transition. "The slowest thing users do is open a product from the grid: a 150 KB chunk and a 250 ms API call after the click."
  2. Remove the waterfall. "The product route has a loader, so the chunk and the data start together. That alone takes us from about 450 ms to about 250 ms."
  3. Add intent prefetch. "On hover and focus, after 80 ms, and on pointer down for touch, we run the route's loader into the shared query cache. With a few hundred ms of head start, most clicks render immediately."
  4. Bound the cost. "We do not prefetch on viewport for per-item data, we skip when Save-Data is on, run prefetches at low priority, and track hit rate and wasted bytes."
  5. Handle correctness. "Prefetched data shares keys with the page and has a 30 second staleTime; mutations invalidate it, and logout clears the cache."

Draw it as a timeline. Put the click in the middle, the hover a few hundred milliseconds to its left, and two bars for the fetch: one starting at the click, one starting at the hover. Interviewers remember a picture with numbers far better than a list of techniques.

How deep to go by level

  • Junior: knows that hover prefetch exists, and the difference between preload (this page) and prefetch (next page).
  • Mid-level: explains route loaders and the waterfall, uses prefetchQuery into the same cache the page reads, and mentions data saving.
  • Senior: reasons with the lead-time formula and hit rates, sizes the server cost, designs guards and metrics, and knows when to use Speculation Rules (MPAs, Chromium) versus router prefetch (SPAs).

Check your understanding · Exercise 8

An interviewer asks how you would make opening an email in a webmail client feel instant. Which points belong in a strong answer? Select all that apply.

Summary

  • Prefetching moves the start of a likely next screen's work earlier; the wait after the click is the fetch time minus the lead time, never less than zero.
  • Preload is for this page and is not a bet; prefetch is a bet on the next page and has a cost when it misses.
  • Fix the waterfall first: route loaders start a route's code and data together, and the same loader can run early on intent.
  • Hover and focus with a 50 to 100 ms delay, plus pointer down for touch, give most of the gain with little waste; keep viewport prefetch for shared code or a few links.
  • A prefetch only helps if the handoff works: same cache, same key, and a staleTime long enough that the page does not refetch at once.
  • Speculation Rules prefetch or prerender whole documents in Chromium; they help multi-page navigations, not client-side route changes.
  • Every prefetch needs guards: data saving, slow connections, low priority, capped concurrency, GET only, and metrics for hit rate and wasted bytes.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is the difference between preloading and prefetching, and when do you use each?

Question 2

How would you make opening a product from a grid feel instant?

Question 3

Your team added prefetching and the network panel shows every product request twice. How do you debug it?

Question 4

When would you use the Speculation Rules API instead of router prefetching?

Question 5

How do you decide whether a prefetch strategy is worth its cost?

Practice it

These design problems lean on prefetching and preloading data.

References

Read next