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 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.
| What | Example | Typical size | Who usually fetches it ahead |
|---|---|---|---|
| Route code | The ProductPage JavaScript chunk | 20 to 150 KB | The framework router (Link prefetch) |
| Route data | GET /api/products/42 as JSON | 2 to 50 KB | Your data layer or the route loader |
| Media | The product's main image | 50 to 300 KB | Rarely; usually loaded by the page itself |
| Whole document | The HTML of /products/42 for a multi-page app | 20 to 100 KB plus subresources | The 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.
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
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:
pointerdownfires when the button or finger goes down. Theclickevent 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.
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.
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=prefetchwrites 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?
For the median, any trigger works: hover gives a few hundred milliseconds and even pointerdown covers most of 80 ms. Hover (with a short delay) is a good default because it is usually right.
For the slow 5 percent, hover still leaves a wait of about 400 ms or more. Prefetching cannot fix a slow tail on its own. Pair it with a fast first paint from data you already have (the name and image from the grid) and work on the backend tail separately.
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.
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,
};
}
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?
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.
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.
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
loaderand can have alazyfunction that imports its code. When the loader is defined on the route itself, the router calls both in parallel. It passesrequest.signal, so a navigation that is replaced by another one aborts its fetch. - TanStack Router: routes have a
loaderwith a built-in cache, anddefaultPreload: "intent"runs a route's loader when itsLinkis 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.
Linkprefetches routes as they enter the viewport in production, up to the nearestloading.jsboundary 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.
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} />;
}
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.
| Function | Returns | If cached and fresh | On error | Use it for |
|---|---|---|---|---|
queryClient.prefetchQuery | A promise of nothing | Does nothing | Swallowed | Hover and idle prefetches: fire and forget |
queryClient.ensureQueryData | The data | Returns it without fetching | Throws | Route loaders that need the data |
queryClient.fetchQuery | The data | Returns it if within staleTime, else fetches | Throws | Imperative reads outside React |
SWR preload(key, fetcher) | A promise of the data | Not checked; a repeat call shares the pending request | Rejects | Prefetching 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.
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.
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
staleTimeago. 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
- 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. staleTimeof zero. TanStack Query's defaultstaleTimeis 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 astaleTimethat matches how fast the data changes, even 10 to 30 seconds.- Different fetch options. For HTTP cache prefetches (
link rel=prefetch, or a plainfetchto warm the cache), the later request must match: same URL, same credentials mode, sameVary-relevant headers. A prefetch without cookies does not satisfy a request with them. - 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. - 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.
// 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 });
}
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?
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.
<!-- 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">
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.
<script type="speculationrules">
{
"prerender": [{
"where": { "and": [
{ "href_matches": "/products/*" },
{ "not": { "selector_matches": ".no-prerender" } }
]},
"eagerness": "moderate"
}],
"prefetch": [{
"urls": ["/cart"],
"eagerness": "eager"
}]
}
</script>
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.
| Eagerness | Roughly when it starts | Default for |
|---|---|---|
immediate | As soon as the rule is seen | List rules (urls) |
eager | Desktop: the pointer rests on a link for about 10 ms. Mobile: a link has been in view for about 50 ms | None |
moderate | Desktop: the pointer rests on a link for 200 ms, or on press. Mobile: viewport heuristics | None |
conservative | On pointer down or touch start | Document 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.
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.prerenderingand wait for theprerenderingchangeevent. Google Analytics already handles this; check any other analytics script you use. - Server visibility. Speculative requests carry a
Sec-Purpose: prefetchheader (with;prerenderfor 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?
No, not for in-app clicks. Speculation Rules prepare document navigations. A client-side route change never loads a new document, so the browser has nothing to swap in. Your router's own prefetching (on hover, into the data cache) is what helps there.
It can still help for links that leave the SPA: from a marketing site into the app, or between separate apps on the same origin.
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.
| Trigger | Fetched | Used | Wasted | Hit rate |
|---|---|---|---|---|
| Viewport, every card | 40 x 8 KB = 320 KB | 8 KB | 312 KB | 1 in 40 (2.5%) |
| Hover, 80 ms delay (3 cards hovered) | 3 x 8 KB = 24 KB | 8 KB | 16 KB | 1 in 3 (33%) |
| Press only | 8 KB | 8 KB | 0 KB | about 100% |
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.
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: onrequest header when the user asks to save data, and exposenavigator.connection.saveDataandeffectiveType. Skip speculative work when data saving is on or the connection is2g. Safari and Firefox expose neither, so a missing value means unknown, not fast. - Only safe requests. Prefetch
GETrequests 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 readSec-Purposefrom 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.
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;
}
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
| Problem | How you notice | Fix |
|---|---|---|
| Stale data after a write | User edits a product, hovers back to it, sees the old price | Invalidate or update the affected keys after every mutation; prefetch respects that |
| Data from the wrong user | After logout and login, a prefetched page shows the old account | Clear the data cache on logout and on account switch |
| Duplicate requests | Same URL twice in the network panel | Share one query definition; set a staleTime |
| Prefetch slows the current page | Worse LCP or INP after enabling prefetch | Low priority, start after the page is idle, cap concurrency |
| Origin overload | Traffic spikes that track scrolling, not visits | Hover instead of viewport, CDN caching, shed Purpose: prefetch first |
| Inflated analytics | Page views rise but engagement does not | Do 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:
Linkprefetches 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 nearestloading.js, so the click shows the loading state instantly while the rest streams.prefetch={false}turns it off for a link, androuter.prefetch(href)prefetches by hand. - React Router (framework mode) and Remix:
Linktakesprefetch="none","intent","render", or"viewport". Withintent, 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 afterdefaultPreloadDelay(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.pageprefetches a page on hover after 65 ms, and Google'squicklinkprefetches 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.
Decision Where should prefetch logic live in a large SPA?
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
- 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."
- 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."
- 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."
- Bound the cost. "We do not prefetch on viewport for per-item data, we skip when
Save-Datais on, run prefetches at low priority, and track hit rate and wasted bytes." - 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) andprefetch(next page). - Mid-level: explains route loaders and the waterfall, uses
prefetchQueryinto 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
staleTimelong 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,
GETonly, 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?
Preloading fetches something the current page will certainly need but would discover late, such as the route's API call or a font named in CSS. It moves certain work earlier and is not a bet, though a preload that does not match the later request downloads twice. link rel=preload is the HTML form.
Prefetching fetches something a future page might need, such as the next route's data or code. It is a bet, so it runs at low priority, on a signal of intent, and is measured by hit rate. Use preload to fix the current page's critical path and prefetch to speed up the next navigation.
Question 2
How would you make opening a product from a grid feel instant?
First remove the waterfall: give the product route a loader so its chunk and its data start together on navigation. Then prefetch on intent: on hover and focus after about 80 ms, and on pointerdown for touch, run the same loader into the shared query cache.
Seed the page with the name, price, and image the grid already has, so the click paints real content even before the full product arrives. Guard it: no viewport prefetch for per-item data, skip on Save-Data, low priority, a staleTime so the page does not refetch, and metrics for hit rate.
Question 3
Your team added prefetching and the network panel shows every product request twice. How do you debug it?
Check the handoff. Compare the query key the prefetch writes with the key the page reads; a number versus string id is a common cause. Then check staleTime: with the default of 0 the page refetches prefetched data on mount.
If the prefetch uses the HTTP cache instead, compare URL, credentials mode, and headers, and check whether the response is cacheable at all. The fix is usually to share one query definition between the link, the loader, and the component.
Question 4
When would you use the Speculation Rules API instead of router prefetching?
Speculation Rules work on document navigations, so they suit multi-page apps and links that leave an SPA. They can prefetch the next HTML or prerender the whole page for a near-instant swap, with eagerness levels that map to triggers like hover and press.
Inside an SPA, client-side route changes never load a new document, so router prefetching into the data cache is what helps. Speculation Rules are also enabled by default only in Chromium browsers today, and prerender runs the page's scripts, so analytics and side effects need care.
Question 5
How do you decide whether a prefetch strategy is worth its cost?
Measure hit rate (prefetches used divided by started), wasted bytes, extra server requests per second, and the time saved per hit on the click-to-content metric. Run it as an experiment against a control.
Then weigh them: cheap JSON with a 33 percent hit rate and 250 ms saved is a good deal; prerendering heavy pages at the same hit rate may not be. Favour high-probability triggers (hover, press, obvious next steps) and skip users on data saving or slow connections.
Practice it
These design problems lean on prefetching and preloading data.
References
- MDN: Speculation Rules API Rules syntax, prefetch versus prerender, eagerness, and
document.prerendering. - Chrome for Developers: Prerender pages in Chrome for instant page navigations Limits, conditions that block speculation, and analytics advice.
- TanStack Query: Prefetching and Router Integration
prefetchQuery,ensureQueryData, and avoiding request waterfalls. - React Router: Link prefetch The
prefetchprop values: none, intent, render, and viewport. - Next.js: Prefetching How
Linkprefetches static and dynamic routes in the App Router. - MDN: rel=preload The
asvalues and whycrossoriginmust match the later request. - MDN: Save-Data header How browsers signal that the user wants to use less data.
- TanStack Router: Preloading Intent, viewport, and render preloading, the 50 ms delay, and how long preloaded data stays fresh.
- instant.page A small hover-prefetch script, with its measurements of hover-to-click and press-to-click times.
Read next
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Deduplication and batchingWhen many parts of a page ask for the same data at once, send one request and share the answer; when they ask for many small pieces at once, collect them into one request. Two small ideas that remove most of the wasted traffic in a component-based app.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.