Optimizing Astro View Transitions Prefetch
You added <ClientRouter /> expecting instant soft navigations, but every click still costs 300–450 ms: DevTools shows the same URL fetched twice — once speculatively on hover, once again at click time — followed by a render-blocking stylesheet request before the transition animation can start.
Root Cause: the Router Navigates with fetch(), So It Can Only Reuse the HTTP Cache
Astro’s View Transitions router is a same-document navigation system. When you click a link it does not hand control to the browser’s navigation machinery; it intercepts the click, calls fetch() for the destination URL, parses the response into a document, diffs the two heads, swaps the body, and wraps the swap in document.startViewTransition(). Every one of those steps happens inside the page you are already on.
That single design decision is what breaks the prefetch. Enabling the router implicitly turns Astro’s prefetch feature on, with prefetchAll: true and defaultStrategy: 'hover', and prefetch is implemented as <link rel="prefetch"> where the engine supports it. A prefetch link deposits its response in the ordinary HTTP cache at the lowest dispatch priority. Chromium additionally holds a non-cacheable prefetch response in a short-lived, navigation-only buffer for about five minutes — but that buffer is keyed to real navigations, and the router never performs one. Its fetch() is an ordinary subresource request, so the only path from the speculative response to the click is the shared HTTP cache, and the response must be genuinely storable and fresh to survive there.
Two response headers destroy that path routinely. Cache-Control: no-store forbids storage outright, so the prefetched bytes are downloaded, used for nothing, and discarded — you pay the full document twice and gain nothing. Vary: Cookie is subtler: the response is stored, but a session cookie that rotates, or a differing credentials mode between the hint and the fetch(), produces a cache key that never matches. In both cases the Network panel shows the tell: two Doc entries for one URL, the first with initiator link rel=prefetch, the second with initiator fetch, separated by however long the user hovered. This is the same reuse contract that governs preload versus prefetch semantics generally — a hint is only a hint until the cache agrees to keep it.
The second stall is independent of the first and is often the larger one. Before calling startViewTransition(), the router adds any <link rel="stylesheet"> the incoming document declares and waits for its load event, deliberately, so the swap never paints unstyled content. Astro’s build emits a per-route CSS chunk whenever a page’s component styles exceed the inline threshold, so a first visit to a route means a serialized stylesheet fetch that begins only after the HTML has been parsed. Document round trip, then head diff, then a second round trip for CSS, then the transition. The diagram below traces that chain with the timings from a real 4G profile.
A third cost hides behind the swap. Islands on the incoming page follow their own client directives, so a client:load island begins its dynamic import() only after the DOM has been replaced. The shared framework runtime is already in the module map from the first page, but the new route’s own island chunk is not, and that fetch is dispatched Low. Ordering those hydrations is the subject of sequencing Astro client directives; here it matters only as the tail of the click-to-interactive measurement.
Minimal Reproduction
Two files and one response header are enough. The layout enables the router, which enables prefetch for every link:
---
// src/layouts/Base.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
<head>
<!-- Mounting the router also switches prefetch on, with prefetchAll: true
and defaultStrategy: 'hover'. The speculative fetch only pays off if
the response can still be in the HTTP cache when the click arrives —
the router reads it back through fetch(), not a navigation. -->
<ClientRouter />
</head>
<body><slot /></body>
</html>
The destination route, meanwhile, is served like this — the default for anything rendered on demand behind a session:
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: no-store
vary: cookie
Hover the link for a second, click it, and watch the Doc filter in the Network panel. You get two entries for one URL. The fix is a header change plus a build setting, not a change to the markup:
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: private, max-age=60, stale-while-revalidate=600
vary: accept-encoding
private keeps the response out of shared CDN caches while allowing the browser to store it; max-age=60 covers the hover-to-click window with room to spare; stale-while-revalidate=600 lets a click that lands minutes later still swap from cache while the revalidation runs in the background. That combination is the same stale-while-revalidate contract used for API responses, applied to whole documents.
Choosing a Strategy per Link
Once the response is reusable, the remaining question is when to speculate. prefetchAll: true is the wrong default for anything with a footer sitemap or a category grid: on a page with sixty anchors, a hover-based strategy is harmless, but flipping those links to viewport or load means sixty documents on the wire during initial load. Pick per link, on measured click-through rate rather than intuition.
Expressed in configuration and markup, that tree looks like this:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
prefetch: {
// ClientRouter defaults this to true. Turning it off makes speculative
// document traffic scale with click probability instead of link count —
// decisive on grids and mega-menus where sixty anchors are in the DOM.
prefetchAll: false,
// hover buys ~200 ms of lead time, enough for a 1-RTT document on a warm
// connection, and spends nothing on links no pointer ever reaches.
defaultStrategy: 'hover',
},
build: {
// Inline per-route CSS under the 4 kB threshold into the HTML. The router
// awaits the load event of every stylesheet the incoming document adds
// before calling startViewTransition(), so an external route chunk puts a
// whole round trip between the click and the first swapped frame.
inlineStylesheets: 'auto',
},
});
<!-- Near-certain next step: pay for it at page load. -->
<a href="/checkout/" data-astro-prefetch="load">Continue to checkout</a>
<!-- Feed items: scroll depth is a decent click-probability proxy, and the
fetch is spread over scroll time rather than bunched at load. -->
<a href="/blog/quic-tuning/" data-astro-prefetch="viewport">QUIC tuning notes</a>
<!-- Side effects on the server and a rare click: never speculate. -->
<a href="/logout/" data-astro-prefetch="false">Log out</a>
Deterministic Fix Protocol
- [ ] 1. Prove the double fetch. Open DevTools, filter the Network panel to
Doc, hover a prefetch-enabled link for a full second, then click. Two requests for one URL — initiatorslink rel=prefetchandfetch— confirm the diagnosis. One request means your prefetch is already being reused and your problem is elsewhere. - [ ] 2. Make prefetchable routes storable. Replace
Cache-Control: no-storewithprivate, max-age=60, stale-while-revalidate=600on every route you speculate on, and removeVary: Cookie. If a route genuinely varies per user, split the personalized fragment into a separate request and leave the shell cacheable. - [ ] 3. Turn off blanket prefetching. Add
prefetch: { prefetchAll: false, defaultStrategy: 'hover' }toastro.config.mjs, then opt links in individually using the decision tree above. Audit the footer and any generated navigation first — those are whereprefetchAllcosts the most. - [ ] 4. Close the stylesheet gate. Set
build.inlineStylesheets: 'auto'. For routes whose CSS exceeds the inline threshold, share one stylesheet across routes instead of emitting per-route chunks, so the file is already cached before the first soft navigation. - [ ] 5. Warm the next route’s island chunks. Call
prefetch()from anastro:page-loadhandler for your one or two highest-probability destinations, and emitmodulepreloadfor island chunks shared across routes so post-swap hydration resolves from the module map rather than the network. - [ ] 6. Keep the connection gate. Leave
ignoreSlowConnectionat its defaultfalse. Astro then skips speculation when the Network Information API reports Save-Data or a 2g-classeffectiveType— verify by enabling Data Saver in the Network conditions drawer and confirming zero speculative document requests. - [ ] 7. Instrument the navigation lifecycle. Time
astro:before-preparationthroughastro:page-loadand log the phase deltas. Lab numbers hide cold-cache and stale-entry cases that only field data exposes. - [ ] 8. Set a budget and re-measure. Targets on a warm connection: exactly one document request per navigation, a stylesheet gate under 10 ms, and click to
astro:page-loadunder 150 ms.
The measurement in step 7 is worth writing once and keeping, because the four router events bracket exactly the phases this page has been picking apart:
// src/scripts/measure-navigation.js — loaded once, survives every swap.
let t0 = 0;
// Fires synchronously when the router intercepts the click, before any
// network work, so it is the only honest zero point for a soft navigation.
document.addEventListener('astro:before-preparation', () => {
t0 = performance.now();
});
// The destination document has been fetched and parsed. A large value here
// means the prefetch was discarded — cache headers, not rendering.
document.addEventListener('astro:after-preparation', () => {
console.log('document ready', Math.round(performance.now() - t0), 'ms');
});
// The DOM has been replaced. The gap from after-preparation is the head diff
// plus the blocking wait on any stylesheet the new page introduced.
document.addEventListener('astro:after-swap', () => {
console.log('DOM swapped', Math.round(performance.now() - t0), 'ms');
});
// Fires after the swap settles and islands on the new page have hydrated —
// the number a user would call "the click finished".
document.addEventListener('astro:page-load', () => {
console.log('page interactive', Math.round(performance.now() - t0), 'ms');
});
Warming the chunks for the single most likely destination costs one extra request and removes the post-swap hydration fetch entirely:
// src/scripts/warm-next-route.js
import { prefetch } from 'astro:prefetch';
// astro:page-load fires on the initial load AND after every swap, so the
// warm-up re-arms itself on each navigation instead of running once.
document.addEventListener('astro:page-load', () => {
const next = document.querySelector('a[data-next-route]');
if (!next) return;
// with: 'link' uses <link rel="prefetch">, which the browser schedules at
// Lowest priority and defers past the current page's critical path; the
// fetch() fallback would compete for the same connection immediately.
prefetch(next.href, { with: 'link' });
});
Before/After Metrics
Lab conditions: throttled 4G (9 Mbps down, 150 ms RTT), Chrome 141, a content route with one client:load island and a 6 kB route stylesheet, measured from astro:before-preparation on a hovered link. “Before” is a default ClientRouter setup behind no-store; “after” applies all eight steps.
| Metric | Before | After | Change |
|---|---|---|---|
| Document requests per navigation | 2 | 1 | −1 |
| Prefetch reuse rate (hovered links) | 0% | 94% | +94 pts |
Click → astro:after-preparation |
185 ms | 20 ms | −89% |
| Blocking stylesheet before the swap | 95 ms | 0 ms | −95 ms |
Click → astro:after-swap |
288 ms | 34 ms | −88% |
Click → astro:page-load |
422 ms | 114 ms | −73% |
| Speculative document bytes at load | 1.9 MB | 240 KB | −87% |
| INP attributed to the navigating click | 312 ms | 96 ms | −69% |
The speculative-bytes row comes from step 3 alone: dropping prefetchAll on a page with 61 anchors removed 58 documents that nobody clicked. The 6% of hovered links that still miss are the ones whose max-age=60 expired before the click; stale-while-revalidate covers most of that tail, and the remainder swap from a revalidated 304 rather than a full transfer. If you need instant navigation with no reuse window at all, escalate those specific destinations to speculation rules prerender instead, accepting that a prerendered activation is a cross-document navigation and bypasses the router.
FAQ
Does <ClientRouter /> prefetch links even if I never set data-astro-prefetch?
Yes. Mounting the router enables Astro’s prefetch feature implicitly, with prefetchAll defaulting to true and defaultStrategy defaulting to hover, so every same-origin anchor becomes a speculation candidate the moment a pointer touches it. That is usually benign on a page with a handful of links and expensive on one with sixty. Set prefetchAll: false explicitly in astro.config.mjs and opt links in one at a time.
Why is the prefetched HTML reused on the first click but not on a later one?
Because freshness expires. A document served with max-age=60 is reusable without revalidation for sixty seconds; after that the router’s fetch() triggers a conditional request, and even a 304 costs a full round trip before the swap can begin. Hover strategies mask this by re-prefetching on the next hover, but viewport and load fire once per page view and go stale silently. Pairing a short max-age with stale-while-revalidate lets a late click swap from cache while revalidation happens in the background.
Should I use speculation rules prerender instead of Astro’s prefetch?
They solve different halves of the problem. A prerender builds the next page in a hidden renderer and activates it as a real cross-document navigation, which bypasses the router entirely: you get an instant page but lose same-document transition state and any island marked transition:persist. Astro’s prefetch only warms the document bytes and keeps the soft navigation. On content sites where nothing needs to survive the swap, prerender wins; where you animate shared elements or keep a media player alive across routes, keep the router and fix its cache path.
Related
- Astro Islands Loading Optimization — the parent topic: how client directives, prefetch strategies and image priority fit together in Astro’s loading model
- Sequencing Astro Client Directives for Faster Interactivity — ordering the island hydrations that run after each swap
- Framework-Specific Loading Strategies — up to the section root