Nuxt Resource Loading Optimization

Nuxt is one of the most opinionated frameworks about network scheduling: it server-renders HTML, emits modulepreload hints for its entry chunks, inlines or extracts data payloads, and speculatively prefetches route chunks the moment a <NuxtLink> scrolls into view. That automation is a double-edged sword. When the defaults align with your page’s real critical path, you get an excellent waterfall for free. When they do not — a hero image that waits behind forty module fetches, a payload fetched twice, a wall of prefetches saturating a mobile connection — the framework’s output actively fights the browser’s fetch priority scheduler, and the failure is invisible unless you know which requests Nuxt initiates and why.

This guide maps every network-relevant mechanism in Nuxt 3 and 4 — rendering modes, payload extraction, NuxtLink smart prefetching, nuxt/image priority controls, useHead hint injection, and nitro route rules — onto the browser’s loading pipeline, then walks through a numbered implementation and a DevTools verification workflow so you can confirm each change actually moved a request earlier or later.


How Nuxt’s rendering modes shape the waterfall

Nuxt is not one rendering strategy; it is four, selectable per route, and each produces a distinct network profile.

Universal rendering (default). The server executes your Vue components, sends fully rendered HTML, and the browser paints before any JavaScript arrives. The HTML carries the app’s state serialized into a #__NUXT_DATA__ script block, plus modulepreload links for the entry chunk and its static import graph. Hydration then replays component setup on the client. The critical path is: HTML → CSS → entry module graph → hydration. First paint does not depend on JavaScript; interactivity does.

Client-side only (ssr: false). The server sends a shell; every meaningful pixel waits for the module graph to download, parse, and execute, then for API calls to resolve. The preload scanner sees almost nothing useful in the shell, so hint injection (covered below) matters far more in this mode.

Prerendering (prerender: true in route rules, or nuxt generate). Pages are rendered to static HTML at build time. Crucially, this is the mode where payload extraction activates: the route’s data payload is written to a sibling _payload.json file instead of being carried by a server on each request, which changes client-side navigation behaviour as described in the next section.

Stale-while-revalidate / incremental (swr and isr route rules). Nitro caches the rendered HTML at the server or CDN edge and revalidates in the background. The browser-side waterfall is identical to universal rendering — the win is a prerender-class TTFB, which pulls every downstream request earlier by the same amount because HTML is the root of the entire request tree.

The timeline below shows the two loading phases every universal-mode Nuxt app has — the initial server-rendered load, and the prefetch-driven client-side navigation that follows.

Nuxt SSR, hydration, and payload prefetch timeline A horizontal timeline from 0 to 3 seconds. The server render and HTML delivery occupy 0 to 0.7 seconds, the entry module graph downloads from 0.5 to 1.2 seconds, hydration executes from 1.2 to 1.8 seconds. After hydration, a NuxtLink entering the viewport triggers an idle-priority prefetch of the next route chunk from 2.0 to 2.4 seconds and its _payload.json from 2.1 to 2.5 seconds, so the click at 2.8 seconds navigates instantly from cache. SSR HTML Entry modules Hydration Route chunk _payload.json server render + TTFB, paint needs no JS via modulepreload hints in head CPU-bound, competes with prefetch idle priority NuxtLink enters viewport click at 2.8s resolves from cache — 0 round trips 0 0.5s 1.0s 1.5s 2.0s 2.5s 3.0s HTML (paint) JS download Hydration (CPU) Speculative prefetch (idle)

_payload.json: data without HTML

On a server-rendered first load, the route’s data (everything resolved through useAsyncData and useFetch) travels inside the HTML as a serialized payload block, so hydration never refetches it. Client-side navigations are different: no new HTML arrives, so the data must come from somewhere. For server-rendered routes, Nuxt calls your data-fetching composables against the API as usual. For prerendered routes, payload extraction (on by default) writes each page’s data to /<route>/_payload.json at build time, and client-side navigation fetches that static file instead of hitting your API — one cacheable GET replaces N API round trips.

The scheduling consequence: _payload.json is a runtime fetch() initiated by the router, invisible to the preload scanner. Nuxt compensates by prefetching it alongside the route chunk when a link becomes a navigation candidate.

<NuxtLink> registers each rendered link with an IntersectionObserver. When the link enters the viewport (the default trigger), Nuxt:

  1. Loads the destination route’s async component chunks. This is not a passive <link rel="prefetch"> — Nuxt drives the chunk’s dynamic import machinery, so the JavaScript is fetched and compiled into the module map. Vite’s build also lists each chunk’s static dependencies, so nested imports come along without a request waterfall (the same graph-flattening problem covered in modulepreload and ES module loading).
  2. Prefetches the destination’s _payload.json when the target is a prerendered route.

Since Nuxt 3.13, the trigger is configurable per link and globally: visibility (default) or interaction (pointer enters or focus lands on the link). Interaction-based prefetch trades ~100–200 ms of lead time for drastically fewer speculative requests — the right trade on link-dense pages. Nuxt also checks the Network Information API and skips prefetching when saveData is set or effectiveType is slow-2g/2g.

Browser engine differences that affect Nuxt’s output

Behaviour Chromium (Blink) Safari (WebKit) Firefox (Gecko)
modulepreload links Nuxt emits Fetch + parse + compile, module map populated Fetch + parse (Safari 17+) Fetch + parse (Firefox 115+)
Dynamic import fetch priority (route chunks) Low (High once needed for navigation) Medium-equivalent Lowest band
Network Information API (saveData gate) Supported — prefetch suppressed on 2g/save-data Not exposed — prefetch always fires Behind a flag — prefetch always fires
fetchpriority attribute (NuxtImg passthrough) Supported (Chrome 101+) Supported (Safari 17.2+) Supported (Firefox 132+)
103 Early Hints for /_nuxt/ assets Acted on (Chrome 103+) Acted on (Safari 17+) Acted on (Firefox 120+)

The Safari row matters most in practice: because WebKit exposes no connection metadata, viewport-triggered prefetch fires unconditionally for every iPhone visitor. If your audience skews mobile Safari, prefer interaction triggers on long link lists.


Spec/API reference

Hint types a Nuxt build emits, and who initiates them

Emitted artifact Mechanism Fetch priority When it fires
<link rel="modulepreload"> (entry + shared chunks) Static HTML head, Vite manifest High Preload scanner, before parser reaches body
<link rel="stylesheet"> (extracted CSS) Static HTML head Highest Preload scanner; render-blocking
<link rel="preload" as="image"> <NuxtImg preload> / useHead High (with fetchpriority="high") Preload scanner
Route chunk fetch NuxtLink prefetch → dynamic import Low Link visible or interacted with, post-hydration
_payload.json fetch NuxtLink prefetch / router navigation Low (prefetch) / High (navigation) With route chunk prefetch, or at click
preconnect / dns-prefetch useHead or app.head config (author-supplied) n/a (connection only) Preload scanner
Link response headers (103 Early Hints) Nitro route rule headers on CDN/origin Per hint type Before HTML body — see 103 Early Hints implementation
Prop / config Values Effect on scheduling
prefetch true (default) / false Disables both chunk and payload prefetch for the link
prefetchOn 'visibility' / 'interaction' / object form Selects the trigger event for speculative fetches
noPrefetch boolean (legacy alias) Same as prefetch=false
prefetchedClass CSS class name Styling hook only; useful for visual prefetch debugging
defaults.nuxtLink.prefetch (nuxt.config) boolean Global kill-switch
defaults.nuxtLink.prefetchOn (nuxt.config) { visibility, interaction } Global trigger policy

Browser support matrix

Feature Chrome Edge Firefox Safari
rel="modulepreload" 66 79 115 17
IntersectionObserver (visibility trigger) 51 15 55 12.1
Network Information API (save-data gate) 61 79 No No
fetchpriority attribute 101 101 132 17.2
103 Early Hints processing 103 103 120 17
rel="preconnect" 46 79 39 11.1

Step-by-step implementation

Step 1 — Audit what Nuxt already emits

Before adding hints, inventory the ones the framework generates, or you will duplicate requests. Load the page with JavaScript disabled (DevTools → Command menu → “Disable JavaScript”) and view the server-rendered head. Count the modulepreload links; more than ~15 is a signal your entry graph needs code-splitting, because each one is a High-priority fetch competing with your LCP image. Then re-enable JavaScript and watch the Network panel for _payload.json and route-chunk requests as you scroll — every visible <NuxtLink> fires a pair.

Step 2 — Assign rendering modes per route with nitro route rules

Route rules are the single highest-leverage loading control in Nuxt because they change what the server sends before the browser schedules anything:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Marketing pages: rendered at build time; payload extraction kicks in,
    // so client-side navigations fetch a static _payload.json instead of the API.
    '/': { prerender: true },
    '/pricing': { prerender: true },

    // Content: cache rendered HTML at the edge for 10 minutes. TTFB drops to
    // edge latency, which pulls every child request earlier by the same delta —
    // HTML is the root of the whole request tree.
    '/blog/**': { swr: 600 },

    // Authenticated app area: skip SSR; nothing here is paint-critical for SEO
    // and server rendering would delay TTFB behind per-user data fetches.
    '/dashboard/**': { ssr: false },

    // Hashed build assets: immutable caching means repeat views schedule
    // zero network requests for the module graph.
    '/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } }
  }
})

Set a global policy, then override per link. On pages with dozens of visible links, visibility-triggered prefetch schedules dozens of Low-priority fetches that still occupy connection slots and — worse — compile JavaScript on the main thread while hydration is running:

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    defaults: {
      nuxtLink: {
        // Interaction-based prefetch: fetch on hover/focus instead of viewport
        // entry. Cuts speculative requests ~10x on link-dense pages while still
        // beating the click by 100-200 ms — enough to hide the round trip.
        prefetchOn: { visibility: false, interaction: true }
      }
    }
  }
})

Step 4 — Elevate the LCP image with nuxt/image

<NuxtImg> defaults are tuned for below-the-fold content (lazy-friendly, no hint). For the LCP element you must opt in to priority:

Never combine preload with loading="lazy" — the preload fetches the bytes eagerly while the lazy decode gate delays paint, wasting the head start.

Step 5 — Inject the remaining hints with useHead

useHead renders into the server-side head, so hints added there are visible to the preload scanner on first byte — unlike DOM-injected hints in a pure SPA. Use it for connection warm-up and route-scoped preloads; use useSeoMeta only for meta tags (it does not handle link elements). The mechanics of choosing between preload and prefetch are unchanged inside Nuxt — the composable is just the delivery vehicle:

// pages/checkout.vue <script setup> — route-scoped hints: only checkout pays for them.
useHead({
  link: [
    // Warm the payment API origin during HTML parse; saves a DNS+TCP+TLS
    // round trip (~150-300 ms) on the first client-side fetch() after hydration.
    { rel: 'preconnect', href: 'https://api.payments.example', crossorigin: '' },
    // This route's icon font is referenced deep inside component CSS —
    // preload makes it scanner-visible instead of CSSOM-discovered.
    { rel: 'preload', href: '/fonts/checkout-icons.woff2', as: 'font', type: 'font/woff2', crossorigin: 'anonymous' }
  ]
})

Site-wide hints belong in nuxt.config.ts under app.head.link instead, so they are emitted statically for every route without executing a composable.

Step 6 — Emit Early Hints for the entry graph (optional, CDN-dependent)

Because nitro route rules can attach response headers, you can surface the entry stylesheet and module as Link headers, which a supporting CDN converts into a 103 interim response — the hints reach the browser while the server is still rendering Vue components. This recovers the render delay that server rendering otherwise adds to asset discovery.


Verification workflow

DevTools Network panel

  1. Open DevTools → Network, enable the Priority column (right-click the column header), and hard-reload.
  2. Confirm the extracted CSS shows Highest, entry modulepreload chunks show High, and your hero image shows High — if the image shows Low, the fetchpriority passthrough or preload prop is not reaching the rendered HTML (check the SSR output, not the hydrated DOM).
  3. Scroll until a <NuxtLink> enters the viewport. You should see the route chunk and, for prerendered targets, _payload.json appear with Low priority and fetch/script initiators.
  4. Click the link. The navigation should complete with zero new network requests for chunk or payload (both served from cache/module map). A repeat _payload.json request here means prefetch and navigation disagree on the URL (commonly a trailing-slash or query-string mismatch).
  5. Repeat with Network throttling: Slow 3G and Chrome’s save-data emulation to confirm prefetches are suppressed.

PerformanceObserver spot check

// Verification: list Nuxt-initiated speculative fetches with priority and reuse.
// Run in Console after scrolling; transferSize 0 on the post-click entry
// confirms the prefetch was consumed instead of duplicated.
performance.getEntriesByType('resource')
  .filter(e => e.name.includes('/_nuxt/') || e.name.includes('_payload.json'))
  .map(e => ({
    file: e.name.split('/').pop(),
    initiator: e.initiatorType,
    priority: e.priority ?? 'n/a',
    transferSize: e.transferSize,
    ms: Math.round(e.duration)
  }));

Build-level checks

Run nuxi analyze and inspect the client bundle: any chunk above ~100 KB that is listed as a modulepreload in the rendered head is delaying hydration for every route that includes it. Split it with a dynamic import so it moves from the eager High-priority set into the on-demand Low set.


Edge cases and gotchas

Prefetch compiling during hydration

NuxtLink’s prefetch executes the chunk’s dynamic import, which means main-thread compile work, not just network transfer. On mid-range mobile hardware, a viewport full of links can queue several hundred milliseconds of script compilation exactly when hydration needs the main thread — inflating Interaction to Next Paint on the initial page even though the network waterfall looks clean. Nuxt defers prefetch registration until after hydration completes, but a fast scroller can still pile up compiles. If a Performance panel trace shows Compile Script slices interleaved with hydration, switch those links to interaction triggers.

Payload duplication

Three distinct bugs produce a doubled payload:

  • Self-referential links. A <NuxtLink> to the current route prefetches the current page’s _payload.json, whose data is already inlined in the HTML. Render such links as plain anchors or set prefetch=false.
  • useFetch key mismatches. If the key Nuxt derives for a useFetch call differs between server and client (e.g. it embeds a timestamp or random id), hydration cannot match the serialized payload entry and silently refetches from the API. Pass an explicit stable key.
  • Non-serializable payload values. Class instances or functions returned from useAsyncData fail payload serialization; the client falls back to refetching. Return plain data or register a payload plugin to transform the type.

Metered connections and Safari

The save-data suppression documented above is Chromium-only. On Safari — where the Network Information API does not exist — every visibility-triggered prefetch fires, including on cellular. There is no framework-level fix; your options are interaction triggers, prefetch=false on heavy links, or a client plugin that flips the global default when a heuristic (viewport width, reduced-data media query) suggests a constrained device.

_payload.json staleness on long-lived tabs

Payload extraction snapshots data at build time. A tab opened before a deploy will prefetch payloads generated by the new build against chunk URLs from the old build; the version mismatch surfaces as a hard reload (Nuxt detects the failed chunk fetch and falls back to a full navigation). This is correct behaviour, but it means your “instant navigation” metrics will show a bimodal distribution around deploys — exclude the reload cohort when evaluating prefetch effectiveness.

Hint duplication between app.head and useHead

useHead deduplicates by tag identity, but a preload declared in nuxt.config app.head and a slightly different one (different as, added crossorigin) declared in a component are treated as distinct links — and the browser will fetch twice, since the requests differ in CORS mode. Declare each preload in exactly one place, and grep the rendered HTML for duplicate href values as part of CI.


FAQ

Why does Nuxt fetch _payload.json when the data is already inlined in the HTML?

The inlined payload only serves the initial server-rendered load. The _payload.json fetch belongs to client-side navigation to a prerendered route, where no fresh HTML is delivered. If you see both on the same first load, a <NuxtLink> pointing at the current route is prefetching the payload of the page you are already on — exclude self-links from prefetching or render them as plain anchors.

Nuxt suppresses prefetch when the Network Information API reports saveData or a 2g-class effectiveType, so metered Chromium users are protected. Safari does not expose that API, so prefetch always fires there. If your traffic is Safari-heavy, switch link-dense pages to interaction-based prefetch, which reduces speculative volume by an order of magnitude while keeping navigations warm.

Should I disable Nuxt automatic prefetching entirely?

Rarely. Prefetched chunks and payloads are what make client-side navigations resolve without a network round trip. Disable it only for links behind authentication walls, very long link lists, or pages where hydration is already bandwidth-constrained — and reach for prefetchOn: 'interaction' before reaching for prefetch: false.

How do I preload a font that only one Nuxt page uses?

Call useHead inside that page component with rel: 'preload', as: 'font', and crossorigin: 'anonymous'. Because useHead executes during server rendering of that route only, the hint lands in the initial HTML head where the preload scanner dispatches it immediately — and no other route pays the cost.