Surface router: /uiux. You are here: enhance-web-instant-nav.
enhance-web-instant-nav — Instant Navigations
Degree of freedom: MIXED. Which pages to speculate [HIGH freedom];
the exclusion list, Sec-Purpose handling, and re-measure [LOW freedom — run exactly].
Applies to MPAs and Next.js App Router (server-rendered routes). Pure SPA
routing → use the framework's prefetch API; Speculation Rules are per-document.
How to reason
- Observe — quote 2nd-page LCP p75, bfcache test result, and the top 5 next-click paths
- Rank — probability × page cost. Prefetch broadly; prerender only ≥ ~50% likely next pages
- Guard — list every URL with GET side effects or per-user state; exclude or downgrade to prefetch
- Ship — rules + server guard + analytics guard in the same PR
- Re-measure — if 2nd-page LCP didn't drop, the rule isn't firing (DevTools → Application → Speculative loads)
Worked example
Observe: blog; first-page LCP 1.9 s, next-article LCP 2.8 s; back button reloads (unload in analytics).
Rank: "next article" 61% of sessions → prerender moderate; category links → prefetch moderate.
Guard: /logout, /account/*, ?utm_* excluded; server returns early on Sec-Purpose.
Ship: rules block + pagehide swap + document.prerendering guard in the GA loader.
Re-measure: next-article LCP 0.3 s (activation), bfcache green, no analytics double-count.
Safety contract [LOW freedom — run exactly]
- Never prerender: logout, cart/checkout, payment, auth, anything whose GET mutates state
- Server: if
Sec-Purpose contains prefetch, skip counters/side effects; return normal HTML
- Client: analytics, ads, consent, A/B run only after
document.prerendering === false or on prerenderingchange
- Never add
unload listeners; never set Cache-Control: no-store on HTML unless legally required
- Cross-origin / cross-site prerender is not attempted
Phase 1 — Measure [LOW freedom — run exactly]
web-vitals/attribution onLCP on the second page in a session; record p75
- DevTools → Application → Back/forward cache → Test; record blocking reasons
curl -sv <url> 2>&1 | grep "< HTTP" — is a 103 emitted today?
- Top next-click paths from analytics (or infer from IA)
# Field: CrUX / PSI p75 for origin + top URLs
npx @lhci/cli autorun --config=lighthouserc.json
curl -sv https://example.com/ 2>&1 | grep -E "< HTTP/.* 103"
Phase 2 — Speculation Rules [HIGH freedom on selection]
Baseline (adjust where to the site's IA):
<script type="speculationrules">
{
"prefetch": [{
"where": { "and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": ["/logout", "/account/*", "/cart*", "/checkout*", "/api/*"] } },
{ "not": { "selector_matches": "[rel~=nofollow], .no-prefetch" } }
]},
"eagerness": "moderate"
}],
"prerender": [{
"where": { "href_matches": "/posts/*" },
"eagerness": "moderate"
}]
}
</script>
- Eagerness:
conservative (pointerdown) → moderate (hover ~200 ms) → eager → immediate (list rules only). Default moderate.
- Query strings:
"expects_no_vary_search": "params=(\"utm_source\" \"utm_medium\")" or exclude ? URLs
- Next.js App Router: keep
<Link> prefetch for RSC payloads; add the block in app/layout.tsx via a raw <script>. On Next.js 16.3+ evaluate built-in Instant Navigations first; if it covers the case, skip prerender rules and keep only prefetch.
- Chromium-first progressive enhancement. Check caniuse at implementation time — never block on Firefox/Safari.
Syntax depth: references/speculation-rules.md.
Phase 3 — bfcache [LOW freedom — run exactly]
- Replace
unload → pagehide; beforeunload only while unsaved input exists
- Close WebSockets / BroadcastChannel in
pagehide; reopen on pageshow if event.persisted
- Drop
Cache-Control: no-store from HTML (keep it on APIs)
- Re-run the DevTools test until green
Blocker list: references/bfcache-blockers.md.
Phase 4 — View Transitions (cross-document) [HIGH freedom]
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
.hero-img { view-transition-name: hero; }
- Pair with prerender: activation + transition = app-like feel
- Progressive: nothing breaks without it
- Keep
view-transition-name unique per document or the transition aborts
prefers-reduced-motion is required (pairs with audit-accessibility)
Phase 5 — 103 Early Hints [LOW freedom — run exactly]
- HTML responses only:
Link: </hero.avif>; rel=preload; as=image; fetchpriority=high, Link: <https://cdn.example>; rel=preconnect
- Node/Next custom server:
res.writeEarlyHints({ link: [...] }); else CDN config
- Do not hint > ~5 resources; do not hint per-user content
- Per-platform:
references/early-hints.md
Self-critique before reporting
- Exclusions listed — every GET-with-side-effects URL is in the PR
- Server checks
Sec-Purpose — grep it
- No double analytics — prerender → activation tested
- Prerender scarce — only pages with ≥ ~50% next-click probability
- Re-measured — 2nd-page LCP p75 dropped, or the rule isn't firing
- Right owner — first-load LCP/INP/CLS →
audit-performance; JS weight → audit-bundle-size
Required output
- Before/after: 2nd-page LCP p75, bfcache status, 103 present, transition on/off
- Rules block + exclusion list
- Server guard + client analytics guard diffs
- One-line rollback: delete the
<script type="speculationrules"> block
Related
audit-performance — first-load CWV / loading priority
audit-bundle-size — JS weight; don't double-prefetch
audit-accessibility — reduced-motion for View Transitions
enhance-pwa — service worker vs speculation / bfcache
1---2name: enhance-web-instant-nav3description: Instant in-site nav: Speculation Rules, View Transitions, bfcache, 103 Early Hints. Use when "instant navigation", "prerender", "early hints", "back button reloads", or "second page is slow". First-load CWV → audit-performance. JS weight → audit-bundle-size. SPA → framework prefetch.4license: MIT5---67> Surface router: `/uiux`. You are here: `enhance-web-instant-nav`.89# enhance-web-instant-nav — Instant Navigations1011**Degree of freedom: MIXED.** Which pages to speculate `[HIGH freedom]`;12the exclusion list, `Sec-Purpose` handling, and re-measure `[LOW freedom — run exactly]`.1314> Applies to MPAs and Next.js App Router (server-rendered routes). Pure SPA15> routing → use the framework's prefetch API; Speculation Rules are per-document.1617## How to reason18191. **Observe** — quote 2nd-page LCP p75, bfcache test result, and the top 5 next-click paths202. **Rank** — probability × page cost. Prefetch broadly; prerender only ≥ ~50% likely next pages213. **Guard** — list every URL with GET side effects or per-user state; exclude or downgrade to prefetch224. **Ship** — rules + server guard + analytics guard in the same PR235. **Re-measure** — if 2nd-page LCP didn't drop, the rule isn't firing (DevTools → Application → Speculative loads)2425## Worked example2627> **Observe:** blog; first-page LCP 1.9 s, next-article LCP 2.8 s; back button reloads (`unload` in analytics).28> **Rank:** "next article" 61% of sessions → prerender moderate; category links → prefetch moderate.29> **Guard:** `/logout`, `/account/*`, `?utm_*` excluded; server returns early on `Sec-Purpose`.30> **Ship:** rules block + `pagehide` swap + `document.prerendering` guard in the GA loader.31> **Re-measure:** next-article LCP 0.3 s (activation), bfcache green, no analytics double-count.3233## Safety contract [LOW freedom — run exactly]3435- Never prerender: logout, cart/checkout, payment, auth, anything whose GET mutates state36- Server: if `Sec-Purpose` contains `prefetch`, skip counters/side effects; return normal HTML37- Client: analytics, ads, consent, A/B run only after `document.prerendering === false` or on `prerenderingchange`38- Never add `unload` listeners; never set `Cache-Control: no-store` on HTML unless legally required39- Cross-origin / cross-site prerender is not attempted4041## Phase 1 — Measure [LOW freedom — run exactly]4243- `web-vitals/attribution` `onLCP` on the *second* page in a session; record p7544- DevTools → Application → Back/forward cache → Test; record blocking reasons45- `curl -sv <url> 2>&1 | grep "< HTTP"` — is a `103` emitted today?46- Top next-click paths from analytics (or infer from IA)4748```bash49# Field: CrUX / PSI p75 for origin + top URLs50npx @lhci/cli autorun --config=lighthouserc.json51curl -sv https://example.com/ 2>&1 | grep -E "< HTTP/.* 103"52```5354## Phase 2 — Speculation Rules [HIGH freedom on selection]5556Baseline (adjust `where` to the site's IA):5758```html59<script type="speculationrules">60{61 "prefetch": [{62 "where": { "and": [63 { "href_matches": "/*" },64 { "not": { "href_matches": ["/logout", "/account/*", "/cart*", "/checkout*", "/api/*"] } },65 { "not": { "selector_matches": "[rel~=nofollow], .no-prefetch" } }66 ]},67 "eagerness": "moderate"68 }],69 "prerender": [{70 "where": { "href_matches": "/posts/*" },71 "eagerness": "moderate"72 }]73}74</script>75```7677- Eagerness: `conservative` (pointerdown) → `moderate` (hover ~200 ms) → `eager` → `immediate` (list rules only). Default `moderate`.78- Query strings: `"expects_no_vary_search": "params=(\"utm_source\" \"utm_medium\")"` or exclude `?` URLs79- Next.js App Router: keep `<Link>` prefetch for RSC payloads; add the block in `app/layout.tsx` via a raw `<script>`. On Next.js 16.3+ evaluate built-in **Instant Navigations** first; if it covers the case, skip prerender rules and keep only prefetch.80- Chromium-first progressive enhancement. Check caniuse at implementation time — never block on Firefox/Safari.8182Syntax depth: `references/speculation-rules.md`.8384## Phase 3 — bfcache [LOW freedom — run exactly]8586- Replace `unload` → `pagehide`; `beforeunload` only while unsaved input exists87- Close WebSockets / BroadcastChannel in `pagehide`; reopen on `pageshow` if `event.persisted`88- Drop `Cache-Control: no-store` from HTML (keep it on APIs)89- Re-run the DevTools test until green9091Blocker list: `references/bfcache-blockers.md`.9293## Phase 4 — View Transitions (cross-document) [HIGH freedom]9495```css96@view-transition { navigation: auto; }97@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }98.hero-img { view-transition-name: hero; }99```100101- Pair with prerender: activation + transition = app-like feel102- Progressive: nothing breaks without it103- Keep `view-transition-name` unique per document or the transition aborts104- `prefers-reduced-motion` is required (pairs with `audit-accessibility`)105106## Phase 5 — 103 Early Hints [LOW freedom — run exactly]107108- HTML responses only: `Link: </hero.avif>; rel=preload; as=image; fetchpriority=high`, `Link: <https://cdn.example>; rel=preconnect`109- Node/Next custom server: `res.writeEarlyHints({ link: [...] })`; else CDN config110- Do not hint > ~5 resources; do not hint per-user content111- Per-platform: `references/early-hints.md`112113## Self-critique before reporting114115- **Exclusions listed** — every GET-with-side-effects URL is in the PR116- **Server checks `Sec-Purpose`** — grep it117- **No double analytics** — prerender → activation tested118- **Prerender scarce** — only pages with ≥ ~50% next-click probability119- **Re-measured** — 2nd-page LCP p75 dropped, or the rule isn't firing120- **Right owner** — first-load LCP/INP/CLS → `audit-performance`; JS weight → `audit-bundle-size`121122## Required output1231241. Before/after: 2nd-page LCP p75, bfcache status, 103 present, transition on/off1252. Rules block + exclusion list1263. Server guard + client analytics guard diffs1274. One-line rollback: delete the `<script type="speculationrules">` block128129## Related130131- `audit-performance` — first-load CWV / loading priority132- `audit-bundle-size` — JS weight; don't double-prefetch133- `audit-accessibility` — reduced-motion for View Transitions134- `enhance-pwa` — service worker vs speculation / bfcache