Controlling Nuxt Payload and Island Hydration

Your Nuxt product page paints its server-rendered markup at 620 ms and then sits inert for another second: the document is 512 KB, 386 KB of it is a __NUXT_DATA__ script block that draws no pixels, and when it finally parses, one synchronous hydration pass walks 2,140 components before the first tap does anything.

That is two separate costs wearing one symptom. The payload is a network cost paid inside the highest-priority stream on the connection; the hydration pass is a main-thread cost paid immediately after it. Nuxt gives you precise controls for both — response projection, server components, and per-component hydration triggers — but the defaults are tuned to make everything work, not to make anything small. The parent topic on Nuxt resource loading covers what the framework emits into the head; this guide covers what it emits into the body, and when that body becomes an interactive application.

Root cause: the payload is document bytes, and hydration is one task

When Nuxt server-renders a route, every value resolved through useAsyncData, useFetch and useState is serialized with devalue into a <script type="application/json" id="__NUXT_DATA__"> block at the end of <body>. Because the type is application/json, the browser does not execute it during parse — it is inert text, and the entry chunk later reads textContent, runs JSON.parse, and walks the flat devalue array to rebuild references and non-JSON types. That design is deliberately fast: JSON.parse on a large object literal is roughly twice as quick as the JavaScript parser on the equivalent source, which is why Nuxt renders JSON payloads rather than an inline assignment.

Fast to parse is not the same as free. Those bytes travel inside the document response, and the document is the root of the request tree — the stream browsers give the top priority slot on the connection, ahead of the stylesheet, ahead of the entry module, ahead of the hero image. Every kilobyte of serialized state you send is a kilobyte the LCP image does not get on a bandwidth-constrained link. This is fetch priority working exactly as specified, applied to cargo that renders nothing: the scheduler cannot know that the last 94 compressed kilobytes of your HTML are a JSON blob rather than markup.

What the 512 KB product-page document is actually made of A single horizontal bar divided into four proportional segments: 8 kilobytes of head and resource hints, 74 kilobytes of rendered markup, 386 kilobytes of __NUXT_DATA__ serialized state, and 44 kilobytes of boot script and modulepreload tags. The payload segment occupies three quarters of the bar. A note below explains that at 1.6 megabits per second the compressed payload adds 470 milliseconds of document transfer, all of it in the connection's highest-priority stream. One /products/[slug] document: 512 KB of HTML, 386 KB of it state that never paints markup __NUXT_DATA__ 386 KB raw · 94 KB brotli · 75% of the response body boot JS head + resource hints — 8 KB. The only part the preload scanner ever reads. rendered markup — 74 KB. Everything the user sees, LCP element included. __NUXT_DATA__ — 386 KB of serialized useAsyncData and useState. Paints nothing. boot script + modulepreload tags — 44 KB of URLs and inline hydration code. At 1.6 Mbps the compressed payload is 470 ms of extra document transfer, and the document is the highest-priority stream on the connection: the hero image waits behind state that renders nothing.

The second cost lands the moment the payload is revived. createSSRApp(...).mount() hydrates the whole component tree in one pass: Vue walks every vnode, claims the matching DOM node, attaches listeners and creates reactive proxies for the state you just deserialized. There is no yield point in the middle of that walk, so on a mid-range phone it is a single long task — 640 ms in the trace this page is drawn from — during which the page looks finished and answers nothing. Every component in the tree pays, including the six that only display text and will never receive an event.

The two costs are also multiplicative in an unhelpful way: bigger payloads mean more reactive proxies to create during hydration, so the same bytes are charged twice, once to the network and once to the main thread.

Islands move the data across the boundary, not just the markup

A server component — a single-file component named Something.server.vue — is rendered by Nitro and delivered as HTML. Its JavaScript is never added to the client graph, and, crucially for this page, the data it fetched is never added to the payload. That last part is what makes islands a payload tool and not merely a bundle tool. A hydrate-never component still renders on the server, so its useFetch result is still serialized for a hydration that will never happen; a server component’s state stops at the Nitro process.

The trade is a network one. When an island’s props change on the client, Nuxt cannot re-render it locally — there is no component code in the browser — so <NuxtIsland> issues a GET to /__nuxt_island/<Name>_<hash>.json and patches the returned HTML into place. That request is a full round trip on the user’s connection, initiated after hydration, at the same low priority as any other post-load fetch().

A NuxtIsland prop change costs a round trip, not a chunk Three lifelines: the browser, the Nitro server island endpoint, and the Vue island renderer. The browser sends a GET to /__nuxt_island/ReviewList_a91f.json when a prop changes. Nitro re-renders the component tree with the new props, the renderer returns JSON containing html, head and slots totalling 34 kilobytes, and Nitro's response is patched into the DOM 210 milliseconds later. No client component code is downloaded at any point. A NuxtIsland prop change: zero client chunks, one real network round trip Browser hydrated app shell Nitro server /__nuxt_island/* Island renderer Vue SSR, no client JS GET /__nuxt_island/ReviewList_a91f.json re-render component tree with new props { html, head, slots } · 34 KB JSON markup patched in · 210 ms round trip Islands trade payload bytes for a round trip: the region's data never enters __NUXT_DATA__ and its chunk never enters the module graph — but each prop change costs a request a hydrated component would not.

So islands are the right tool for a region that is expensive to serialize and rarely re-parameterised — a spec table, a review list, a rendered description — and the wrong tool for a filter panel whose props change on every keystroke. Astro makes the same trade with a different default; the comparison in Astro islands loading optimization is worth reading if you are choosing between architectures rather than tuning one.

Minimal reproduction

Two files produce both symptoms at once: an oversized payload and a hydration pass that includes components nobody will touch.

<!-- pages/products/[slug].vue -->
<script setup lang="ts">
const route = useRoute();

// The handler resolves 60 full product records at ~6 KB each. useAsyncData
// serializes what the handler RESOLVES, not what the template reads — so all
// 360 KB is written into __NUXT_DATA__ even though four fields are rendered.
const { data: related } = await useAsyncData('related', () =>
  $fetch(`/api/products/${route.params.slug}/related`)
);
</script>

<template>
  <ProductHero :product="product" />
  <!-- Every one of these hydrates in the same synchronous pass as the hero,
       including the spec table, which has no listeners and never changes. -->
  <ProductSpecTable :spec="product.spec" />
  <ProductReviews :product-id="product.id" />
  <RelatedGrid :items="related" />
</template>

Measure before you change anything. The payload block is inert text in the DOM, so its exact transferred size is one expression away, and the per-key ranking tells you which two or three keys are the entire problem:

// plugins/payload-budget.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  // Runs after devalue has revived the payload but before mount(), which is the
  // only window where these are still plain objects — after hydration they are
  // reactive proxies and JSON.stringify walks the proxy traps instead.
  const raw = document.getElementById('__NUXT_DATA__')?.textContent ?? '';
  console.log('payload', (new Blob([raw]).size / 1024).toFixed(1), 'KB');

  console.table(
    Object.entries(nuxtApp.payload.data ?? {})
      .map(([key, value]) => ({ key, kb: +(JSON.stringify(value).length / 1024).toFixed(1) }))
      .sort((a, b) => b.kb - a.kb)
  );
});

Choosing where each region lives

Every region of the page belongs in exactly one of four places, and the choice is decided by two questions: does it ever respond to input, and is it needed before the user scrolls. Answer those honestly per region and the payload shrinks as a side effect.

Deciding where a product-page region lives Three stacked questions on the left branch to four outcomes on the right. If the region never responds to input, it becomes a server component with zero payload bytes and zero client JavaScript. If it responds but is below the fold, it becomes a Lazy component with hydrate-on-visible. If it is in the first viewport and its data is static, it hydrates eagerly but the payload is projected with pick. If its data changes after load, it becomes a NuxtIsland that refetches server-rendered markup on prop change. Deciding where a product-page region lives — and what each choice costs Does this region ever respond to input? Is it inside the first viewport? Does its data change after load? SpecTable.server.vue 0 payload bytes, 0 client JS LazyReviews hydrate-on-visible chunk and hydration deferred to scroll eager hydration + pick() serialize only the rendered fields NuxtIsland with props server markup refetched per change no yes no yes no yes Only the third branch adds bytes to __NUXT_DATA__; only the fourth adds a request to the wire after load.

The hydration directives in the third branch are Nuxt’s delayed-hydration macros, available on any auto-imported component addressed with the Lazy prefix. They compile to Vue’s async-component hydration strategies, so the server-rendered HTML is emitted normally in every case — only the chunk fetch and the hydration pass move.

Directive Chunk fetched when Hydration when Payload cost Use for
(none) in the eager module graph first mount pass full above-the-fold interactive regions
hydrate-on-visible element intersects viewport on intersection full below-the-fold widgets
hydrate-on-idle first idle callback on idle full secondary panels in view
hydrate-on-interaction first pointer/focus event on that event full menus, accordions, dialogs
hydrate-after="2000" after the given delay after the delay full polling or ambient widgets
hydrate-never never never still full static regions that keep a component API
*.server.vue never never none regions with no client behaviour at all

The two bold cells are the point of the table. hydrate-never deletes the JavaScript but keeps the data, because the component still runs on the server; only a server component removes both.

Deterministic fix protocol

  • [ ] 1. Measure the payload and rank it by key. Install the plugin above and record two numbers per route: total __NUXT_DATA__ bytes, and the size of the largest key. In practice two or three keys are 90% of the block, and those are the only ones worth touching.
  • [ ] 2. Label every key. For each ranked key decide: rendered once (belongs on the server), mutated by a client handler (must be serialized), or nothing reads it (delete the fetch). The third category is more common than teams expect — leftovers from a removed feature keep fetching and keep serializing.
  • [ ] 3. Project the response at the fetch site. Add pick for top-level keys or transform for a reshape, and narrow the API query to match. Only the transformed result is serialized, so this is the single highest-leverage change in the list.
  • [ ] 4. Move render-only regions to server components. Rename the component to *.server.vue. Both its client chunk and its data leave the browser in one edit, and the rendered HTML is byte-identical.
  • [ ] 5. Stage the remaining hydration. Apply hydrate-on-visible below the fold and hydrate-on-interaction to menus and dialogs. The eager pass now covers the hero and the navigation only.
  • [ ] 6. Audit island refetch traffic. With DevTools open, exercise every filter and toggle and watch for /__nuxt_island/ requests. A region that refetches on each keystroke belongs on the client, not on an island.
  • [ ] 7. Check prerendered routes too. Rebuild and compare the generated _payload.json sizes. Client-side navigation reads those files, so an unshrunk payload just relocates the stall to the next route.
  • [ ] 8. Add a CI byte budget. Assert a maximum __NUXT_DATA__ size per rendered route. Payload regressions are invisible in every other check you run.

Steps 3 and 4 together produce this shape:

// pages/products/[slug].vue — <script setup>
const route = useRoute();

const { data: related } = await useAsyncData(
  // A key that is byte-identical on server and client. A key containing a
  // timestamp or a random id cannot be matched during hydration, so the client
  // silently refetches the whole list over the network after mount.
  () => `related:${route.params.slug}`,
  // Ask the API for the five fields the card renders. This cuts the Nitro-to-API
  // transfer as well as the payload — transform alone would not.
  () => $fetch(`/api/products/${route.params.slug}/related?fields=slug,title,price,image,badge`),
  {
    // transform runs in the Nitro process during SSR and ONLY its result is
    // serialized, so the discarded fields never reach the document at all.
    transform: (rows) => rows.map((r) => ({
      s: r.slug, t: r.title, p: Math.round(r.price), i: r.image
    }))
  }
);
<template>
  <ProductHero :product="product" />

  <!-- Rendered by Nitro, delivered as HTML. Its 210 KB of spec rows are never
       serialized and its chunk is never in the client graph — the region simply
       does not exist as far as the browser's module map is concerned. -->
  <ProductSpecTable :spec-id="product.id" />

  <!-- SSR markup is still emitted, so there is no layout shift and no blank box.
       Only the chunk fetch and the hydration patch wait for intersection, which
       moves ~180 ms of compile-and-patch out of the paint-to-interactive window. -->
  <LazyProductReviews :product-id="product.id" hydrate-on-visible />

  <!-- Idle: scheduled in a requestIdleCallback, so it can never compete with the
       hero image decode or with the user's first tap. -->
  <LazyRecentlyViewed hydrate-on-idle />
</template>

Server components and <NuxtIsland> are behind one config flag. Turning it on changes nothing until a component opts in, so it is safe to enable globally and migrate one region per pull request:

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    // Enables *.server.vue components and the /__nuxt_island/ endpoint they are
    // re-rendered through. Inert until a component is renamed, so this is not a
    // scheduling change on its own — it only unlocks step 4.
    componentIslands: true
  }
})

Finally, the budget from step 8. Run it against the rendered HTML in CI, not against a dev server, because dev builds serialize extra debugging state:

// scripts/payload-budget.mjs — node scripts/payload-budget.mjs .output/public
// Fails the build on regression. A byte budget is the only check that catches a
// payload growing back, because it never shows up as a slow request or an error.
const BUDGET_KB = 60;
const html = await fs.readFile(file, 'utf8');
const block = html.match(/id="__NUXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)?.[1] ?? '';
const kb = Buffer.byteLength(block, 'utf8') / 1024;
if (kb > BUDGET_KB) throw new Error(`${file}: payload ${kb.toFixed(1)} KB > ${BUDGET_KB} KB`);

Before and after

Measured on the same /products/[slug] route: Slow 4G at 1.6 Mbps, 4× CPU slowdown, 210 ms server response time, warm HTTP/3 connection, median of nine runs.

Eager whole-tree hydration versus a projected payload with staged hydration Two three-row timelines on a shared axis from zero to 1,800 milliseconds. Before: the 118 kilobyte document completes at 820 milliseconds, payload revival runs to 1,000, and one hydration pass over 2,140 components runs to 1,640, where the page becomes interactive. After: the 40 kilobyte document completes at 410 milliseconds, revival ends at 440, hydrating the shell and hero ends at 640, and the review island hydrates at 1,100 on scroll, outside the critical window. Same route, same rendered pixels: one eager hydration pass versus a staged one before interactive at 1,640 ms document · 118 KB brotli 820 ms parse + revive payload 180 ms hydrate 2,140 components 640 ms after interactive at 640 ms document · 40 KB brotli 410 ms parse + revive payload 30 ms hydrate shell + hero 200 ms reviews hydrate on scroll 0 450 900 1,350 1,800 ms The payload was not moved elsewhere — it was never serialized. The spec table and review list now render on the server, so 345 KB of source data stops at the Nitro process and is charged to nothing on the client.
Metric Before After Change
__NUXT_DATA__ raw / brotli 386 KB / 94 KB 41 KB / 11 KB −89%
Document transfer 118 KB 40 KB −66%
Document complete 820 ms 410 ms −50%
Payload parse + revive 180 ms 30 ms −83%
Hydration pass 640 ms 200 ms −69%
Time to interactive 1,640 ms 640 ms −61%
Largest contentful paint 1,520 ms 880 ms −42%
Total blocking time 520 ms 140 ms −73%
INP, p75 field 290 ms 130 ms −55%
Components in the eager pass 2,140 610 −71%
Island requests after load 0 1 per filter change (34 KB) new cost

The last row is the trade, stated plainly. Filter changes now cost a 210 ms round trip that a hydrated component would have served locally. That is the correct bargain when filtering is rare and the page load is universal, and the wrong one when the filter is the product — which is why step 6 exists. If those island requests turn out to be frequent, the same region is a better fit for hydrate-on-interaction, keeping the chunk out of the eager graph while still paying for it only once.

FAQ

Does pick or transform reduce what my server fetches from the API?

No. Both run after the handler has already resolved, inside the Nitro process, so the full API response still crosses the server-to-API link and is still parsed there. What they change is serialization: only the picked or transformed value is written into __NUXT_DATA__. The distinction matters when the upstream call is the slow part — a 400 ms API round trip is 400 ms of TTFB whether you serialize the result or not. Narrow the API query for the server cost and use transform for the client cost; they are two different budgets and only the second one is visible in the browser’s waterfall.

Why does a component marked hydrate-never still put data in the payload?

Because hydrate-never only suppresses hydration, not server rendering. The component still executes during SSR, so every useFetch and useAsyncData inside it runs, resolves, and is serialized for a hydration that will never consume it — you removed the JavaScript and kept all the bytes. The give-away is a payload key whose component has no client chunk at all. Convert the component to *.server.vue instead: a server component’s state lives and dies inside the Nitro process, so both the chunk and the payload entry disappear in a single rename.

Do prerendered routes have the same problem in _payload.json?

Yes, byte for byte. Payload extraction writes the same serialized state to a static _payload.json beside the route’s HTML, so the bytes leave the initial document — which genuinely helps first load — but a client-side navigation must download and revive that file before the route can render. An oversized payload becomes a navigation stall instead of a load stall, and because <NuxtLink> prefetches those files speculatively, the cost is multiplied by every visible link rather than paid once. The projections in step 3 shrink both artifacts, and the prefetch volume itself is tuned separately in tuning NuxtLink prefetch behavior.


Related