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.
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().
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.
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
pickfor top-level keys ortransformfor 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-visiblebelow the fold andhydrate-on-interactionto 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.jsonsizes. 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.
| 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
- Nuxt Resource Loading Optimization — up to the parent topic: rendering modes, the hints Nuxt emits into the head, and the nitro route rules that decide them
- Tuning NuxtLink Prefetch Behavior — the sibling guide on the navigation side: choosing visibility or interaction triggers for chunk and payload prefetch
- Astro Islands Loading Optimization — the same island trade made by a framework that defaults to shipping no JavaScript at all
- Framework-Specific Loading Strategies — up to the section root