Navigation & Preloading — the instant-click engine

[check: …]
📖 guide (.md) ← react deep dive
📖 Pair this live React playground with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on frontend/tanstack-start: Navigation & Links (the basics of links + active state — this bundle goes to the preloading engine).

fetch on click vs. fetch on intent

A normal <Link> is "click then fetch" — the user clicks, the router runs the route's beforeLoad → loader, downloads the JS chunk, THEN paints. Preloading inverts the timeline: the router starts that work the instant a link signals intent (hover / tap / enters viewport). By the time the click lands, the result is already in the cache and the navigation is INSTANT. Same data, same loaders — just moved earlier.

pieceroledefault
preload trigger policy on <Link>: false | 'intent' | 'viewport' false (opt-in)
'intent' preload on mouseenter (desktop hover) + pointerdown/touchstart (mobile tap), after preloadDelay ms delay 0ms
'viewport' preload via IntersectionObserver the moment the link scrolls into view eager — all visible links
staleTime how long a preloaded entry is considered fresh (no refetch on navigate) 30_000ms (navigations: 0)
gcTime how long an unused preloaded entry survives in memory before garbage collection 300_000ms (5min)

Deduplication is free: two links to the same route share one in-flight preload; if a preload is mid-flight when the click arrives, the result is reused — no double fetch.

1 · the preloading pipeline (edit me)

2 · Babel compiles JSX → element tree

<NavigationDemo/> becomes React.createElement(NavigationDemo). Hovering a link calls onIntent(path,'hover') → if strategy matches, startPreload dedups against cache[path], kicks the setTimeout "fetch", and the log + cache snapshot re-render. A later click reads the same cache[path] — hit → INSTANT.

// (hit "compile & render" to see Babel's output)

3 · live React (the preloading pipeline, proven)

Hover a link to fire intent (with hover selected), then click it — the navigation is INSTANT because the cache already holds the result. Switch to none, hover a fresh link — nothing preloads; clicking it shows LOADING. The gold-check runs this whole sequence automatically and asserts every step.

live: · gold: hover Users → preload → click INSTANT · none → hover Settings (no preload) → click LOADING

trigger → dedup → cache → navigate

scenariowhat happenswhy
preload="intent" + hover mouseenter → after preloadDelaypreloadRoute() hover ≈ "I'm about to click" — cheapest signal with real intent
preload="intent" + tap pointerdown/touchstart → preload starts (same code path as hover) mobile has no hover; intent covers both event types
preload="viewport" IntersectionObserver fires → preload every link in view eager — best for landing pages, costs bandwidth on link farms
two links, same route only the first triggers a fetch; second logs CACHE HIT dedup key = route path; in-flight promise is shared
click while preloading navigation awaits the in-flight preload → reuses result no double fetch; click never cancels the preload
click after staleTime entry is stale → refetch in background (or block, per staleReloadMode) freshness window balances latency vs. data currency
link leaves viewport, unused after gcTime (default 5min) the entry is garbage-collected preloads don't leak memory forever