Tuning NuxtLink Prefetch Behavior

On listing and navigation-heavy pages, every <NuxtLink> that scrolls into view fires a route-chunk and payload prefetch, and forty visible links translate into eighty-plus background requests that compete with the LCP image and API calls still on the wire.

<NuxtLink> inherits its prefetching model from the idea that a link a user can see is a link a user might click. By default, Nuxt registers each rendered link with a shared IntersectionObserver; when the link enters the viewport (and the browser reports an idle period), Nuxt prefetches the lazy-loaded route chunk for the target page and, on payload-extracted sites, the route’s _payload.json data file as well. For a page with five links this is an excellent trade. For a category page, mega-menu, footer sitemap, or infinite-scroll feed, the trigger condition — visibility — is satisfied by dozens of links simultaneously at the exact moment the page is still loading its own critical resources.

The requests are issued at low network priority, which is the correct labeling but an incomplete defense. Dispatch priority orders requests leaving the queue; it does not partition bandwidth among responses already in flight. Once thirty chunk responses are streaming on the shared HTTP/2 or HTTP/3 connection, they consume congestion window and downlink bytes that the still-loading hero image and first-party API calls need — the same contention mechanics that govern prefetch semantics at the browser level apply regardless of how politely the framework schedules the trigger. On top of transfer cost, each arriving chunk is parsed and compiled on the main thread, and each payload is JSON-parsed, adding CPU work during the window that determines interactivity metrics.

The payload half of the prefetch is the part teams most often underestimate. A route chunk is shared across all pages of the same route pattern and caches once, but _payload.json is per destination URL: a product grid with 48 item links prefetches 48 distinct payload files, none of which are reusable for any other link. On data-rich pages, payload files routinely outweigh the route chunk itself, so the aggregate prefetch bill scales with catalog density rather than with anything the user will actually do — the median user clicks one of those 48 links, making roughly 98% of the prefetched bytes waste.

Visibility vs Interaction Trigger, on the Wire

NuxtLink prefetch triggers: visibility burst vs interaction gating Two timelines. The top one shows visibility-triggered prefetching: a dense burst of chunk and payload requests between 0.6 and 2.4 seconds overlapping the LCP image download, delaying LCP to 3.0 seconds. The bottom one shows interaction-triggered prefetching: the page loads cleanly with LCP at 1.7 seconds and just two prefetch requests fire at 4.6 seconds when the user hovers a link, before the click at 5.0 seconds. 0s 1s 2s 3s 4s 5s prefetchOn: visibility LCP image LCP 3.0s prefetches 40 links visible → ~80 chunk + payload requests share bandwidth with the hero prefetchOn: interaction LCP image LCP 1.7s prefetches hover at 4.6s → 2 requests, click lands at 5.0s with cache warm LCP image transfer speculative prefetch (wasted for ~98% of links) intent-gated prefetch

Minimal Reproduction

A product grid is enough to reproduce the flood. Open the Network panel, filter to _nuxt/ and _payload.json, and load the page:


The fix keeps prefetching but moves the trigger from visibility to demonstrated intent, and sets the same policy globally so future pages inherit it:


// FIXED (site-wide): nuxt.config.ts — make interaction the default trigger
// so link-heavy pages stop paying a load-time tax for speculative bytes.
export default defineNuxtConfig({
  experimental: {
    defaults: {
      nuxtLink: {
        prefetch: true,
        // visibility off: prefetch volume now scales with clicks, not links
        prefetchOn: { visibility: false, interaction: true },
      },
    },
  },
});

Deterministic Fix Protocol

  • [ ] 1. Quantify the flood. Load the worst link-heavy page with the Network panel open, filter to _nuxt/ plus _payload.json, and record request count and total bytes fired in the first 5 seconds. This is your baseline waste figure.
  • [ ] 2. Confirm the overlap with critical resources. In a Performance panel trace, check whether prefetch transfers coincide with the LCP image’s download window. If the hero finishes before the burst begins, your problem is cache churn and CPU, not LCP — the fix is the same but the success metric differs.
  • [ ] 3. Set the global default to interaction. Add the experimental.defaults.nuxtLink.prefetchOn block shown above to nuxt.config.ts. A global default beats per-link props because new templates inherit the policy without review vigilance.
  • [ ] 4. Re-enable visibility prefetch only where it earns its cost. Primary CTAs and the top navigation’s two or three highest-traffic targets can carry :prefetch-on="{ visibility: true }" individually — a handful of high-click-probability links is what the visibility trigger was designed for.
  • [ ] 5. Verify the interaction trigger fires early enough. In the Network panel, hover a card link and confirm the chunk and payload requests dispatch on pointerenter, then click and confirm both are served from cache (memory or disk) at navigation time. If click-to-render still waits on the payload, your CDN’s TTFB on _payload.json is the next target.
  • [ ] 6. Add connection-aware gating for the links that kept visibility prefetch. Nuxt already skips prefetching when the Network Information API reports saveData or a 2g-class effectiveType; verify this by emulating Data Saver in DevTools (Network conditions drawer) and confirming zero prefetch requests. Extend the same check to any custom prefetch composables you ship.
  • [ ] 7. Re-measure the baseline page. Request count in the first 5 seconds should drop to roughly the route’s own resource count, LCP should recover the bandwidth the burst was consuming, and click-through navigation timing should remain within ~50 ms of the visibility-prefetch baseline.

Before/After Metrics

Lab conditions: throttled 4G (9 Mbps, 150 ms RTT), category page with 48 product links, payload extraction enabled, warm CDN. “Before” is default visibility prefetching; “after” is interaction-triggered prefetch site-wide.

Metric visibility (default) interaction Change
Requests in first 5 s 131 39 −70%
Speculative bytes transferred 2.9 MB 0 KB at load −2.9 MB
LCP 3,010 ms 1,720 ms −43%
Main-thread script parse (load window) 480 ms 165 ms −66%
Click → route render (hovered link) 90 ms 130 ms +40 ms
Click → route render (cold link, no hover) 90 ms 340 ms +250 ms

The last row is the honest cost: a keyboard user or instant-clicker who gives no hover signal pays a cold fetch at click time. On a warm HTTP/3 connection that penalty is a few hundred milliseconds on one navigation — against a multi-second LCP improvement on every landing. Sites that want to close even that gap can pair interaction-triggered chunks with speculative prefetching rules for full documents on their highest-traffic next pages.

FAQ

Does switching to interaction-based prefetch make navigation feel slower?

Rarely, and usually unmeasurably. Desktop users hover 150–400 ms before clicking, which covers a route chunk and payload fetch from a warm CDN connection; on touch devices the trigger is touchstart, which still precedes the navigation commit by tens of milliseconds. The users who genuinely pay are those who click with zero prior signal — and their one-time penalty is far smaller than the load-time cost that visibility prefetching imposed on everyone.

Prefetch requests are Low priority — why do they still hurt loading?

Because priority governs when requests leave the queue, not how bandwidth is shared once responses stream. Thirty in-flight prefetch responses on the same HTTP/2 or HTTP/3 connection consume congestion window, downlink bytes, and — after arrival — main-thread parse time, all during the window in which the LCP image and first-party data calls are also transferring. Low priority makes the flood polite at dispatch; it does not make it free.

Use the defaults block in nuxt.config.ts: experimental.defaults.nuxtLink.prefetch: false turns the machinery off entirely, while prefetchOn: { visibility: false, interaction: true } keeps prefetching but gates it behind hover, focus, or touch. The second option is almost always the better default — you retain near-instant navigations for users who signal intent while eliminating the per-link load-time tax.


Related