Skip to content
jsmanifest logojsmanifest

Next.js Prefetching in App Router: How Link Partial Prefetch Works and Why Your Pages Still Feel Slow

Next.js Prefetching in App Router: How Link Partial Prefetch Works and Why Your Pages Still Feel Slow

Next.js App Router prefetching promises instant navigation, but partial prefetch defaults and router cache expiration create surprising slowdowns. Learn how Next.js 16.3's new primitives fix the root causes and how to debug blocking navigations before users notice.

Developers adopt Next.js App Router expecting server components to eliminate client-side waterfalls and prefetching to deliver instant navigation. The reality surprises them: links feel sluggish, dynamic routes block for seconds, and the DevTools network tab shows requests firing only after the user clicks. The prefetch happened, the data arrived, but the navigation still waits.

The confusion stems from three behaviors that collide in production: partial prefetch stops at the first Suspense boundary instead of loading the full page, the router cache expires prefetched data after 30 seconds for dynamic routes, and the client cannot render anything until the server responds with the missing pieces. The result is a server-driven app that feels slower than the client-side SPA it replaced.

flowchart LR
    A("User hovers link") --> B("Partial prefetch loads shell")
    B --> C("Router cache expires after 30s")
    C --> D("User clicks expired link")
    D --> E("Navigation blocks on server response")
    
    style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7

Next.js 16.3 introduces partial prefetching as an opt-in feature, giving developers fine-grained control over what loads before navigation. Combined with the new cache components primitive, teams can mark reusable UI as cacheable and let Next.js extract loading shells from any route, not just routes with a loading.tsx file. The client now has enough data to render an instant transition, and the server streams dynamic content as it resolves.

flowchart LR
    A("User hovers link") --> B("Partial prefetch loads shell + cached data")
    B --> C("User clicks link")
    C --> D("Shell renders instantly")
    D --> E("Dynamic data streams in")
    
    style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
    style E stroke:#7c9cf0,fill:#142544,color:#eaf2ff

This post explains how App Router prefetching works under the hood, why the default behavior creates slow navigations, and how to adopt the new primitives to make every link click feel instant without breaking dynamic content.

Key Takeaways

  • Next.js App Router partial prefetch (the default) stops at the first Suspense boundary, not the full page, causing slow navigations when routes lack explicit loading shells.
  • The router cache expires prefetched data after 30 seconds for dynamic routes and 5 minutes for static routes, triggering re-fetches on click if the user waits too long.
  • Next.js 16.3's partial prefetching and cache components let developers mark reusable UI as cacheable and control exactly what loads before navigation.
  • Instant Insights in the Next.js DevTools automatically surfaces slow navigations during development, eliminating guesswork about which links need optimization.
  • Full prefetch with prefetch={true} loads the entire destination page ahead of time, but costs bandwidth and can serve stale personalized content if cached data expires.

The <Link> component in Next.js App Router triggers prefetching when it enters the viewport or when the user hovers over it. The exact behavior depends on the prefetch prop and whether the destination route is static or dynamic.

By default, <Link> uses prefetch="auto", which means Next.js decides what to prefetch based on the route type. For static routes, the full route loads. For dynamic routes, Next.js fetches the route down to the first loading.tsx boundary, a behavior called partial prefetch.

The distinction between static and dynamic routes matters because Next.js treats them differently at build time. A route is static if it contains no dynamic segments (like [id]), no generateStaticParams is missing, and it does not read request-specific data like cookies or headers. Everything else is dynamic.

// app/blog/page.tsx - static route
export default function BlogPage() {
  return <div>Blog posts render at build time</div>;
}
 
// app/blog/[slug]/page.tsx - dynamic route
export default function PostPage({ params }: { params: { slug: string } }) {
  // Reads dynamic segment, so Next.js treats this as dynamic
  return <div>Post: {params.slug}</div>;
}

When a user hovers over a <Link href="/blog/post-1">, Next.js sends a request to the server. For a static route, the server returns the pre-rendered HTML and RSC payload. For a dynamic route, the server returns only the layout and any shared UI down to the first Suspense boundary, then stops. The page component itself does not load until the user clicks.

The official Next.js documentation (as of version 16.3) describes this behavior in the prefetch prop reference: "For dynamic routes, the partial route down to the nearest segment with a loading.js boundary will be prefetched." If no loading.tsx file exists, the prefetch ends at the layout, and the navigation blocks until the server responds with the page.

flowchart TD
    A("Link enters viewport") --> B("Check route type")
    B --> C("Static route?")
    C -->|Yes| D("Prefetch full page")
    C -->|No| E("Dynamic route: prefetch to first loading.js")
    E --> F("No loading.js?")
    F -->|Yes| G("Prefetch only layout")
    F -->|No| H("Prefetch layout + loading shell")
    
    style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7

The router cache stores prefetched data with a time-to-live (TTL) that depends on the route type. Static routes cache for 5 minutes. Dynamic routes cache for 30 seconds. After the TTL expires, clicking the link triggers a new server request even if the prefetch completed successfully.

This explains why navigations sometimes feel instant and sometimes block. If the user clicks within 30 seconds of hovering, the cached data is fresh and the navigation completes immediately. If the user waits 31 seconds, the cache expires, and the client must wait for the server to re-render the route before the navigation can proceed.

Partial Prefetch vs Full Prefetch: What Gets Loaded When

The prefetch prop on <Link> accepts three values: "auto" (the default), true, and false. Each produces different behavior and costs.

prefetch="auto" defers to Next.js to decide what to load. For static routes, this means the full route. For dynamic routes, this means a partial prefetch that stops at the first loading.tsx boundary. The goal is to balance performance with bandwidth: prefetch enough to make navigation feel instant without loading content the user may never see.

prefetch={true} forces a full prefetch for both static and dynamic routes. The client downloads the entire page, including all data-fetching and rendering, before the user clicks. This guarantees instant navigation but wastes bandwidth if the user never visits the link. It also introduces stale data problems for personalized routes: if the prefetched page contains user-specific content, and the user waits long enough for the cache to expire, clicking the link shows stale content until the server responds with fresh data.

prefetch={false} disables prefetching entirely. The client requests the route only after the user clicks. This saves bandwidth but guarantees a slow navigation because the client must wait for the server to render the page before the transition can complete.

Next.js 16.3 changes the behavior when partial prefetching is enabled via partialPrefetching: true in next.config.ts. With this flag, prefetch="auto" now prefetches the route's app shell (the static and cached content) instead of stopping at the first loading.tsx boundary. The app shell includes layouts, templates, and any components marked with 'use cache', giving the client enough data to render a loading state immediately on click.

Prefetch valueStatic route behaviorDynamic route behaviorRouter cache TTLBest for
"auto" (default)Full routePartial (to first loading.tsx)Static: 5min, Dynamic: 30sMost links
trueFull routeFull routeStatic: 5min, Dynamic: 30sCritical navigation paths
falseNo prefetchNo prefetchN/ARarely-visited routes, infinite scroll lists
"auto" (with partialPrefetching: true)App shell + cached contentApp shell + cached contentConfigurable per cache tagNext.js 16.3+ apps

The tradeoff between partial and full prefetch becomes clear in a dashboard app. A sidebar contains 20 links to different reports. Full prefetch downloads all 20 pages when the sidebar renders, consuming megabytes of bandwidth and server resources even if the user only views one report. Partial prefetch downloads the shell for each page (layouts, navigation, headers) but waits to load report-specific data until the user clicks. The navigation feels instant because the shell paints immediately, and the data streams in as it resolves.

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <nav>
        {/* Partial prefetch: loads shell, not data */}
        <Link href="/dashboard/sales">Sales Report</Link>
        <Link href="/dashboard/inventory">Inventory</Link>
        <Link href="/dashboard/analytics">Analytics</Link>
      </nav>
      <main>{children}</main>
    </div>
  );
}
flowchart LR
    subgraph Partial["Partial Prefetch (Default)"]
        P1("User hovers link") --> P2("Fetch layout + loading.js")
        P2 --> P3("User clicks")
        P3 --> P4("Render shell instantly")
        P4 --> P5("Wait for page data")
    end
    
    subgraph Full["Full Prefetch (prefetch=true)"]
        F1("User hovers link") --> F2("Fetch layout + page + data")
        F2 --> F3("User clicks")
        F3 --> F4("Render complete page instantly")
    end
    
    style P5 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style F4 stroke:#34d399,fill:#0b3b2e,color:#d1fae5

The diagram shows why partial prefetch feels faster for the initial shell render but slower for the complete page, while full prefetch either delivers the entire page instantly or serves stale content if the cache expired. Choosing the right strategy depends on whether the route contains dynamic content that changes frequently.

The Router Cache Problem: When Prefetched Data Goes Stale

The router cache in Next.js App Router exists to avoid redundant server requests. When a prefetch completes, Next.js stores the result in memory with a TTL. Subsequent navigations to the same route read from the cache instead of hitting the server, making back-and-forth navigation instantaneous.

The problem surfaces when the TTL expires before the user clicks. For dynamic routes, 30 seconds is short enough that normal user behavior (reading content, scrolling, comparing options) exhausts the cache before the navigation happens. The user clicks, the cache check fails, and the client blocks while waiting for the server to re-render the route.

This behavior surprises developers because the prefetch succeeded. The DevTools network tab shows the request completing with a 200 status. But when the user clicks 35 seconds later, a second identical request fires, and the navigation waits for that response before proceeding. The prefetch wasted bandwidth and delivered no performance benefit.

The stale data problem is worse for personalized routes. Consider a product page that shows the user's cart count in the header. If the page prefetches when the user hovers the link, and the user adds items to the cart before clicking, the prefetched page shows the old cart count until the new server response arrives. The transition looks instant, but the cart count is wrong for 200-300ms, creating a flicker as the correct data streams in.

Next.js addressed this in 16.3 by tying cache invalidation to the 'use cache' directive. When a component or function is marked with 'use cache', Next.js generates a cache key based on the function's arguments and the user's session. If the arguments or session change, the cache key changes, and the cached data is no longer valid. This lets developers opt specific components into granular caching while keeping the rest of the page fresh.

// app/dashboard/sales/page.tsx
import { unstable_cache } from 'next/cache';
 
async function getSalesData(userId: string) {
  'use cache';
  const response = await fetch(`/api/sales?user=${userId}`);
  return response.json();
}
 
export default async function SalesPage({ params }: { params: { userId: string } }) {
  const data = await getSalesData(params.userId);
  
  return (
    <div>
      <h1>Sales Report</h1>
      {/* This data is cached per user and stays fresh across navigations */}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

The official Next.js 16.3 release notes state: "It's more explicit and composable than our previous server-side caching APIs, and now, it also brings client-side caching to Next.js for the first time." The 'use cache' directive integrates with prefetching by letting the router cache store function-level results instead of full routes. When a prefetch happens, Next.js extracts the cached data, bundles it with the shell, and sends it to the client. The user clicks, the shell renders instantly, and only the non-cached dynamic data requires a server round-trip.

flowchart TD
    A("User hovers link") --> B("Prefetch checks cache keys")
    B --> C("Cached data fresh?")
    C -->|Yes| D("Return cached + shell")
    C -->|No| E("Fetch fresh data")
    E --> F("Store with new cache key")
    F --> D
    D --> G("User clicks within TTL")
    G --> H("Render shell + cached data instantly")
    
    style H stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px

The cache key mechanism solves the stale data problem but introduces a new complexity: cache invalidation. If the sales data changes server-side while the client holds a cached copy, the user sees outdated information until the cache expires or the route re-validates. Next.js provides revalidation APIs (revalidatePath, revalidateTag) to expire caches on demand, but developers must call them explicitly when data changes.

Router cache diagram showing TTL expiration and re-fetch behavior

The image illustrates how the router cache expires after the TTL and triggers a new server request, even when the original prefetch completed successfully. The gap between prefetch and click determines whether the navigation feels instant or blocks.

Controlling Prefetch Behavior: Practical Code Examples

Developers control prefetching at three levels: per-link via the prefetch prop, per-route via loading.tsx files, and globally via next.config.ts flags.

The prefetch prop is the most direct control. Setting prefetch={false} disables prefetching for a specific link, useful for links in infinite scroll lists where prefetching would waste bandwidth. Setting prefetch={true} forces a full prefetch, useful for critical navigation paths like the main call-to-action on a landing page.

// app/components/product-list.tsx
'use client';
 
export default function ProductList({ products }: { products: Product[] }) {
  return (
    <div className="grid">
      {products.map((product) => (
        <Link
          key={product.id}
          href={`/product/${product.id}`}
          // Disable prefetch for long lists to save bandwidth
          prefetch={false}
        >
          {product.name}
        </Link>
      ))}
    </div>
  );
}

The loading.tsx file provides a route-level loading shell that prefetching uses to render an instant transition. When a dynamic route includes a loading.tsx file, partial prefetch stops at that file and sends the loading component to the client. The user clicks, the loading state renders immediately, and the page content streams in as the server resolves data-fetching.

// app/blog/[slug]/loading.tsx
export default function Loading() {
  return (
    <div className="blog-post-skeleton">
      <div className="skeleton-title" />
      <div className="skeleton-author" />
      <div className="skeleton-content" />
    </div>
  );
}

Without loading.tsx, partial prefetch has nothing to send, so the navigation blocks until the full page arrives. The Next.js DevTools Instant Insights panel highlights this as a slow navigation and suggests adding a loading file.

Next.js 16.3 adds two config flags that change prefetching behavior globally. Setting cacheComponents: true enables the new cache components primitive, letting developers mark reusable UI with 'use cache' and have Next.js extract it during prefetch. Setting partialPrefetching: true changes the default prefetch="auto" behavior from "stop at first loading.tsx" to "prefetch app shell plus cached content."

// next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
 
export default nextConfig;

These flags unlock fine-grained control over what loads before navigation. A product page can mark the header, footer, and navigation as cached, while leaving product-specific content (price, availability, reviews) dynamic. The prefetch sends the shell, the user clicks, the shell renders instantly, and the dynamic content streams in.

// app/product/[id]/page.tsx
import { Suspense } from 'react';
 
async function Header() {
  'use cache';
  return <header>Site Header</header>;
}
 
async function ProductDetails({ id }: { id: string }) {
  const product = await fetch(`/api/products/${id}`).then(res => res.json());
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
    </div>
  );
}
 
export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <>
      <Header />
      <Suspense fallback={<div>Loading product...</div>}>
        <ProductDetails id={params.id} />
      </Suspense>
    </>
  );
}

The cache components approach solves the stale data problem by caching at the component level instead of the route level. The header never changes, so it stays cached indefinitely. The product details change frequently, so they skip the cache and fetch fresh data on every navigation. The user sees an instant shell render followed by a smooth stream of dynamic content, no flicker, no stale data.

Next.js 16.3 Instant Navigations and the Future of Prefetching

Next.js 16.3 introduces Instant Navigations, a suite of features designed to make server-rendered apps feel as responsive as client-side SPAs without sacrificing the benefits of server rendering. The headline feature is partial prefetching, which builds on the cache components primitive to extract loading shells from any route, not just routes with a loading.tsx file.

The official Next.js 16.3 announcement states: "Next.js can extract this UI and load it into the client prior to a navigation, making your app feel as snappy as an SPA once users start clicking around it." The key word is "extract." Instead of requiring developers to manually define loading shells in separate files, Next.js analyzes the route's component tree, identifies cached components, and bundles them with the prefetch response. The client receives a ready-to-render shell even if the route has no loading.tsx file.

Partial prefetching also changes how prefetch payloads are structured. In prior versions, a prefetch request returned a monolithic RSC payload containing the entire route. In 16.3, the payload includes three layers: the app shell (layouts, templates, cached components), URL-specific data (dynamic segments, search params), and truly dynamic content (user-specific data, real-time updates). The client renders the app shell immediately, paints URL-specific data as it arrives, and streams dynamic content last.

This staged handoff eliminates the binary choice between instant-but-stale full prefetch and accurate-but-slow partial prefetch. The app shell is always safe to cache because it does not depend on user state or request timing. URL-specific data caches per route, so navigating between products on the same site reuses the shared shell and only fetches product-specific content. Dynamic content bypasses the cache entirely, guaranteeing freshness at the cost of a small delay.

// app/dashboard/[reportId]/page.tsx
import { Suspense } from 'react';
 
async function DashboardShell() {
  'use cache';
  return (
    <div className="dashboard-layout">
      <nav>Dashboard Navigation</nav>
      <aside>Filters</aside>
    </div>
  );
}
 
async function ReportData({ reportId }: { reportId: string }) {
  const data = await fetch(`/api/reports/${reportId}`).then(res => res.json());
  
  return <div>{JSON.stringify(data, null, 2)}</div>;
}
 
export default function ReportPage({ params }: { params: { reportId: string } }) {
  return (
    <>
      <DashboardShell />
      <Suspense fallback={<div>Loading report...</div>}>
        <ReportData reportId={params.reportId} />
      </Suspense>
    </>
  );
}

The diagram below shows the execution flow when partial prefetching is enabled. The prefetch extracts the app shell, the user clicks, the shell renders instantly, and the report data streams in as the server resolves the fetch.

flowchart LR
    A("User hovers link") --> B("Prefetch extracts app shell")
    B --> C("User clicks link")
    C --> D("App shell renders instantly")
    D --> E("Fetch report-specific data")
    E --> F("Stream data to client")
    
    style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
    style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5

The Instant Insights panel in the Next.js DevTools automatically detects slow navigations and suggests fixes. If a route lacks a loading shell, the panel shows a warning and generates a prompt that teaches AI agents how to add one. If a route has a loading shell but still feels slow, the panel identifies which components are blocking the critical path and suggests marking them with 'use cache'.

The official documentation (Next.js 16.3 release notes) describes Instant Insights as "a new devtool that automatically surfaces slow navigations." The panel runs in development mode only, monitoring every client-side transition and measuring the time from click to first paint. If the time exceeds a threshold (currently 300ms), the panel flags the navigation as slow and logs the component stack that blocked rendering.

This eliminates the guesswork that plagued prior versions. Developers no longer need to profile every link manually or instrument their code with performance marks. The DevTools highlight the problem, explain the root cause, and provide a concrete fix. All without leaving the browser.

Instant Insights panel in Next.js DevTools showing a slow navigation warning

The image shows the Instant Insights panel flagging a navigation to /dashboard/sales as slow because the route lacks a loading shell. The panel suggests adding a loading.tsx file or marking reusable components with 'use cache' to unlock instant navigation.

Debugging Slow Navigations: Finding the Real Culprit

Slow navigations in Next.js stem from four common causes: missing loading shells, expired router cache, blocking data fetches, and oversized prefetch payloads. Identifying the root cause requires inspecting the network tab, the React DevTools profiler, and the Next.js Instant Insights panel.

The network tab shows when prefetch requests fire and how long they take to complete. A prefetch that completes in 50ms but triggers a second identical request on click indicates a cache expiration problem. The user waited longer than the TTL, so the router cache invalidated the prefetched data and fetched it again.

The React DevTools profiler shows which components are blocking the critical path. If a navigation waits 2 seconds before painting anything, the profiler flame graph reveals which component is running slow. The usual suspects are data-fetching components that call fetch without Suspense boundaries, heavy computations in the render phase, and third-party libraries that block the main thread.

The Instant Insights panel ties these signals together by showing the full navigation trace. The panel logs the click event, the prefetch check, the cache hit or miss, the first paint, and the final layout shift. If the navigation is slow, the panel highlights the step that took the longest and suggests a fix.

flowchart LR
    A("User clicks link") --> B("Check router cache")
    B --> C("Cache valid?")
    C -->|Yes| D("Render from cache")
    C -->|No| E("Fetch from server")
    E --> F("Wait for response")
    F --> G("Parse RSC payload")
    G --> H("Commit to DOM")
    
    style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style F stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7

The diagram shows the decision tree the router follows during navigation. If the cache is valid, the render happens immediately. If the cache expired, the navigation blocks while waiting for the server response. The gap between cache check and server response is where slowness accumulates.

Fixing slow navigations requires matching the solution to the root cause. If the cache expired, either shorten the time between prefetch and click (by prefetching on hover instead of on viewport entry) or extend the cache TTL by marking components with 'use cache'. If the navigation blocks on data fetches, wrap the slow components in Suspense boundaries and add a loading fallback. If the prefetch payload is too large, reduce it by setting prefetch={false} on low-priority links or splitting the route into smaller chunks.

The useLinkStatus hook (introduced in Next.js 16.2) provides immediate feedback during navigation. When a user clicks a link, the hook returns { pending: true } until the navigation completes, letting developers show a loading indicator without waiting for the server response.

'use client';
 
import { useLinkStatus } from 'next/link';
 
export default function NavigationIndicator() {
  const { pending } = useLinkStatus();
  
  if (!pending) return null;
  
  return (
    <div className="fixed top-0 left-0 right-0 h-1 bg-blue-500 animate-pulse" />
  );
}

The hook debounces fast navigations by adding a 100ms delay before returning pending: true. This prevents the loading indicator from flashing on screen for navigations that complete in under 100ms, reducing visual noise for instant transitions.

The official Next.js documentation (version 16.3) recommends combining useLinkStatus with CSS transitions to create a smooth loading experience: "You can 'debounce' the hint by adding an initial animation delay (e.g. 100ms) and starting as invisible (e.g. opacity: 0)." This gives the server time to respond before showing any feedback, avoiding flicker on fast networks.

For further reading on Next.js caching and performance, see the related posts Next.js 15 Caching Changes, Next.js App Router Layouts and Templates, and Next.js Caching Mental Model 2026.

Frequently Asked Questions

The router cache expires prefetched data after 30 seconds for dynamic routes. If the user clicks after the TTL, the client fetches the route again even though the prefetch completed. Use 'use cache' to extend the TTL or reduce the delay between prefetch and click by prefetching on hover.

What is the difference between partial prefetch and full prefetch in App Router?

Partial prefetch loads the route down to the first Suspense boundary, sending the layout and loading shell but skipping page-specific data. Full prefetch loads the entire page including all data. Partial prefetch saves bandwidth but may feel slower if no loading shell exists. Use prefetch={true} to force full prefetch on critical links.

How does Next.js 16.3 partial prefetching change the default behavior?

In Next.js 16.3, enabling partialPrefetching: true makes prefetch="auto" send the app shell plus cached components instead of stopping at loading.tsx. This gives the client enough data to render an instant transition without requiring a separate loading file for every route.

When should I disable prefetching with prefetch={false}?

Disable prefetching for rarely-visited routes, infinite scroll lists, and links that appear in large quantities (like a product catalog with 1000 items). Prefetching wastes bandwidth if the user never clicks the link, and disabling it has no downside if the route is not performance-critical.

How do I debug which component is blocking a slow navigation?

Use the Instant Insights panel in the Next.js DevTools to identify slow navigations, then inspect the React DevTools profiler to see which component is blocking the critical path. The profiler flame graph shows render times per component, revealing bottlenecks like unwrapped data fetches or heavy computations in the render phase.

Making Next.js Navigations Feel Instant Without Breaking Dynamic Content

Next.js prefetching promises instant navigation but delivers slow transitions when the defaults collide with dynamic routes, expired caches, and missing loading shells. The confusion stems from partial prefetch stopping at Suspense boundaries, router cache expiration after 30 seconds, and the client blocking until the server responds with missing data.

Next.js 16.3 fixes this with partial prefetching, cache components, and Instant Insights. Developers mark reusable UI with 'use cache', and Next.js extracts it during prefetch, giving the client enough data to render an instant shell. Dynamic content streams in as the server resolves it, and the DevTools highlight any navigation that takes longer than 300ms, eliminating guesswork about which links need optimization.

Adopting the new primitives requires enabling two flags in next.config.ts and marking cached components with 'use cache'. The payoff is a server-rendered app that feels as responsive as a client-side SPA, with no stale data, no flicker, and no guesswork about when prefetches expire.