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 with payload extraction enabled the _payload.json file 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.

What 48 visible NuxtLink cards actually fetch: one chunk, forty-eight payloads A graph. On the left, a category page with 48 visible NuxtLink cards. It fans out to two nodes: the shared route chunk for /product/[slug].js, 62 KB fetched once and reused by every card; and the per-URL _payload.json files, 48 of them at about 59 KB each for 2.83 MB. On the right, a grid of 48 squares shows that exactly one payload is clicked and 47 are discarded. Category page 48 NuxtLink cards all visible at once Route chunk /product/[slug].js 62 KB, fetched once Per-URL payload _payload.json × 48 59 KB each, 2.83 MB one fetch serves all 48 cards 48 fetched, 1 clicked, 47 wasted

Visibility vs Interaction Trigger, on the Wire

NuxtLink prefetch triggers: visibility burst versus interaction gating Two timelines on a shared five-second axis. The top one shows visibility-triggered prefetching: a dense burst of chunk and payload requests between 0.6 and 2.4 seconds overlaps the LCP image download and delays 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. prefetchOn: visibility (default) LCP image LCP 3.0 s prefetches 40 links visible, about 80 chunk and payload requests on the wire prefetchOn: interaction LCP image LCP 1.7 s prefetches hover at 4.6 s, 2 requests; the click at 5.0 s hits a warm cache 0 s 1 s 2 s 3 s 4 s 5 s LCP image transfer speculative prefetch (~98% wasted) 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:

<!-- BROKEN: pages/category/[slug].vue — every card link prefetches its
     route chunk AND its per-URL payload the moment it becomes visible,
     which for a 48-item grid means ~96 requests during initial load. -->
<template>
  <section class="grid">
    <NuxtLink
      v-for="product in products"
      :key="product.id"
      :to="`/product/${product.slug}`"
    >
      {{ product.name }} — {{ product.price }}
    </NuxtLink>
  </section>
</template>

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 (per link): the prefetch now fires on hover/focus/touchstart —
     one link's chunk + payload, ~300ms before the click needs them. -->
<template>
  <section class="grid">
    <NuxtLink
      v-for="product in products"
      :key="product.id"
      :to="`/product/${product.slug}`"
      :prefetch-on="{ visibility: false, interaction: true }"
    >
      {{ product.name }} — {{ product.price }}
    </NuxtLink>
  </section>
</template>
// 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.

Steps 4 and 6 are the two exceptions to the global default, and they resolve in a fixed order — connection quality first, then click probability:

Choosing the prefetch trigger for one NuxtLink A left-to-right decision tree. Every NuxtLink on a link-heavy page is first tested for connection quality: if Data Saver is on or effectiveType reports 2g, prefetching is skipped, which Nuxt already handles through the Network Information API. Otherwise the link is tested for click probability: above roughly one in five it earns prefetch-on visibility, which suits top navigation and primary calls to action; below that it stays on the site-wide interaction default, meaning zero prefetches at load and two on hover. Every NuxtLink on a link-heavy page Data Saver on, or effectiveType 2g? yes no Click-through above 1 in 5 for this link? yes no Prefetch skipped Nuxt already gates on the Network Information API prefetch-on visibility top nav and primary CTA: 2-3 high-click links only prefetch-on interaction site default: 0 prefetches at load, 2 on hover

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, where the eagerness setting plays exactly the role prefetchOn plays here — moderate and conservative are the browser-level equivalents of trading visibility for hover.

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