JavaScript Hydration & Partial Rendering
Partial hydration is the single biggest lever a static site generator gives you over Interaction to Next Paint (INP). A static site already wins Largest Contentful Paint and Cumulative Layout Shift for free, because the HTML is pre-rendered. INP is the one Core Web Vital you can still wreck after the fact, and you wreck it by running too much JavaScript on the main thread during interaction. The fix is structural: ship the page as static HTML, then hydrate only the components that are genuinely interactive instead of booting a framework runtime over the entire document.
This guide is for engineers and documentation teams who already ship a static site and want their INP to match their Largest Contentful Paint. We cover how to draw hydration boundaries, how to pick the right client directive, how to split and budget the JavaScript that remains, how to coordinate islands that need to talk to each other, how to keep server and client renders in sync, and how to prove the result with field data. It sits inside the broader Performance Optimization & Core Web Vitals for SSGs effort, where hydration is the stage that owns INP.
The mental model to hold throughout: a static generator hands you a page that is already interactive-looking — every link works, every heading is styled, every table is readable — with no JavaScript at all. Hydration is not what makes the page work; it is what makes a handful of components respond to input. Framing it that way flips the default. Instead of asking "which components can I avoid hydrating?" you ask "which components genuinely need to hydrate at all?" On a documentation or marketing page the honest answer is usually three or four, and everything else is markup the browser already knows how to paint.
What You Will Learn
- Drawing hydration boundaries — classifying components as static or interactive so most of the page ships as plain HTML.
- Choosing a client directive — when
client:load,client:idle, andclient:visibleare each correct, and what each costs INP. The full comparison against booting the whole framework lives in Astro Islands vs Full Hydration Performance. - Trimming the JavaScript that remains — splitting and tree-shaking bundles, covered for template-only generators in How to Reduce Bundle Size in Eleventy Builds.
- Coordinating islands — sharing state between independently-hydrated components without reintroducing a page-wide runtime.
- Proving it in production — wiring field measurement so INP regressions surface, detailed in Measuring INP on Static Sites with Real-User Monitoring.
Setting Hydration Boundaries
Start by classifying every component on a page as static or interactive. On a typical documentation or marketing page, the answer is "static" for the large majority — headings, prose, tables, images, navigation that is just links. Interactive means it responds to input on the client: a search box, a tabbed widget, a chart with tooltips, a copy-to-clipboard button.
The audit is worth doing explicitly, once, per page template. Walk the component tree and put each node in one of three buckets, because the middle bucket is where most wasted JavaScript hides:
| Bucket | What it is | Hydration verdict |
|---|---|---|
| Purely static | Prose, headings, images, tables, link-only nav | No directive — renders to HTML, ships 0 KB |
| Static with a sprinkle | A <details> accordion, a "copy code" button, a mobile menu toggle | No framework island — a few lines of vanilla JS or a native element |
| Genuinely interactive | Search-as-you-type, live chart, filterable list, multi-step form | An island with the narrowest directive that works |
The second bucket is the trap. A collapsible section does not need React, Vue, or Svelte — <details>/<summary> is native, accessible, and costs nothing. A copy button is one addEventListener. Reaching for a framework component here is how a page that should ship 20 KB ends up shipping 120 KB. Reserve islands for the third bucket only.
In Astro, the boundary is explicit through the client:* directives. A component with no directive renders to HTML at build time and ships zero JavaScript. A component with a directive becomes an island — its own small bundle that hydrates on its own trigger.
---
import InteractiveChart from '../components/InteractiveChart.jsx';
import StaticTable from '../components/StaticTable.astro';
---
<main>
<h1>Static Dashboard</h1>
<p>Non-interactive content ships as plain HTML — zero JS.</p>
<StaticTable data={rows} /> <!-- no directive: pure HTML -->
<InteractiveChart client:visible data={chartData} />
</main>
In Eleventy, Hugo, and Jekyll there is no directive system, but the principle is identical: ship static HTML and attach a small Alpine.js or vanilla-JavaScript handler only on the elements that need behavior. There is no global framework runtime to boot, so the static parts cost nothing. Coordinate hydration with Font Loading Strategies for Static Sites and Image Optimization Pipelines in Astro so island JavaScript isn't competing with font and image loading for the same main thread during the first seconds of a page load.
Choosing the Right Client Directive
The directive you pick decides when the island's JavaScript runs, and that timing is the lever on INP. The three you reach for most:
client:load— hydrate immediately, in the page's initial load. Reserve it for above-the-fold controls that must respond the instant the page paints, like a header search box. It is the most expensive choice because the work lands during the same window the user is first trying to interact.client:idle— hydrate once the browser reports an idle period (viarequestIdleCallback). Right for non-urgent interactivity like a comment form or a newsletter widget that the user won't touch in the first moment.client:visible— hydrate when the component scrolls near the viewport (viaIntersectionObserver). This is the default choice for most widgets, because below-the-fold work costs the early interaction window nothing.
<SearchBox client:load /> <!-- above the fold, must be instant -->
<NewsletterForm client:idle /> <!-- can wait for an idle moment -->
<DataChart client:visible /> <!-- below the fold, defer to scroll -->
Two more directives handle edge cases worth knowing. client:media="(max-width: 768px)" hydrates only when a media query matches — the right tool for a component that is interactive on mobile but static (or absent) on desktop, like an off-canvas menu, so desktop users never download its JavaScript. client:only="react" skips server rendering entirely and hydrates on the client alone; it is a last resort for components that genuinely cannot render without the DOM, and it forfeits the pre-rendered HTML, so use it sparingly and always pair it with a placeholder to avoid a layout shift.
Order the directives by how much of the early interaction window they spend. client:load spends it eagerly; client:visible and client:media may never spend it at all if the user never scrolls to or matches the component. That ordering is the whole game for INP.
The default should always be no directive. Add the narrowest directive that still works only when a component is genuinely interactive. The measured difference between these choices and a full-framework boot is large:
| Hydration strategy | Client JS shipped | Total Blocking Time | INP (field p75) |
|---|---|---|---|
| Full hydration (whole page) | 186 KB | 410 ms | 290 ms |
All islands client:load | 92 KB | 240 ms | 210 ms |
Mixed load/idle/visible | 92 KB | 90 ms | 140 ms |
Same islands, same code — moving the non-urgent ones off client:load cut Total Blocking Time by more than half and pulled field INP under the 200 ms "good" threshold. The side-by-side against booting the entire framework is in Astro Islands vs Full Hydration Performance.
Splitting and Budgeting the JavaScript That Remains
Even with disciplined boundaries, the islands you do ship should be split so they load in parallel and cache independently. Isolate vendor code from your island code so a change to one island doesn't bust the whole cached bundle:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) return 'vendor';
if (id.includes('/islands/')) return 'islands';
},
},
},
},
});
Then make the JavaScript budget a build gate so a regression fails CI rather than reaching production:
#!/usr/bin/env bash
MAX_KB=100
ACTUAL_KB=$(du -k dist/assets/*.js | awk '{ sum += $1 } END { print sum }')
if [ "${ACTUAL_KB:-0}" -gt "$MAX_KB" ]; then
echo "FAIL: client JS ${ACTUAL_KB}KB exceeds ${MAX_KB}KB budget"; exit 1
fi
echo "PASS: client JS within budget (${ACTUAL_KB}KB)"
For template-only generators that don't bundle at all, the trimming work is different — you scope scripts per route and run them through esbuild. That is covered end to end in How to Reduce Bundle Size in Eleventy Builds, where scoping a global script down to the three pages that needed it cut the sitewide payload from 78 KB to 11 KB.
Budget per route, not per site. A single global budget hides the page that quietly grew to 300 KB because it averages against the many pages that ship nothing. Emit the client chunk size for each entry point and gate on the worst offender, so one heavy route can't ride in on the site's overall thinness.
Coordinating Islands That Share State
The moment you have more than one island, they usually need to talk. A filter control in the header changes what a results list below shows; a theme toggle flips a preference every other island reads. This is where teams panic and reach back for a page-wide framework — the exact runtime that partial hydration exists to avoid. You don't need it.
Each island is its own hydration root, so you cannot pass props between them the way you would inside one component tree. Instead, share a tiny framework-agnostic store that every island imports. A signals or nano-store library is a few hundred bytes and lives outside any framework runtime:
// stores/filters.js — imported by every island that reads or writes the filter
import { atom } from 'nanostores';
export const activeTag = atom('all');
// Two separate islands, hydrated independently, staying in sync through the store.
import { useStore } from '@nanostores/react';
import { activeTag } from '../stores/filters.js';
export function TagPicker() {
const tag = useStore(activeTag);
return <select value={tag} onChange={e => activeTag.set(e.target.value)}>…</select>;
}
export function ResultList({ items }) {
const tag = useStore(activeTag);
const shown = tag === 'all' ? items : items.filter(i => i.tags.includes(tag));
return <ul>{shown.map(i => <li key={i.id}>{i.title}</li>)}</ul>;
}
The store is the entire coordination layer. Both islands hydrate on their own triggers — the picker might be client:load while the list is client:visible — and the shared atom keeps them consistent without a parent component or a global app instance. If your islands don't even need shared reactive state, a plain CustomEvent on document is enough: one island dispatches, the others listen. The rule is the same as with directives — reach for the smallest mechanism that solves the actual problem, and never let coordination become an excuse to hydrate the whole page.
Partial Hydration Beyond Astro
The directive syntax is Astro's, but the technique is not. Any generator can ship static HTML and attach scoped behavior, and the INP win is identical because it comes from how much runs on the main thread, not from which tool emitted the HTML.
In Eleventy, the is-land web component gives you the same lazy triggers declaratively — on:visible, on:idle, on:interaction — around any framework or vanilla component, so a page stays static until the island's trigger fires. In Hugo or Jekyll, where there is no component runtime at all, you write the interactive bit as a small module and load it only on the routes that use it. And across all of them, a progressive-enhancement library like Alpine.js or petite-vue lets you annotate existing server HTML with behavior in place, so the markup the crawler and the first paint see is the same markup the user interacts with — no client re-render, no framework boot.
<!-- Alpine: behavior attached to server-rendered HTML, no page-wide runtime -->
<div x-data="{ open: false }">
<button x-on:click="open = !open">Details</button>
<div x-show="open">Revealed on the client, static on the server.</div>
</div>
Whichever generator you use, the discipline is the same: default to zero JavaScript, scope what remains to the element that needs it, and pick the latest trigger the interaction tolerates.
Keeping Server and Client Renders in Sync
Hydration assumes the client can reuse the server's HTML. When the two renders disagree, the framework throws away the server markup and re-renders on the client — a hydration mismatch that costs main-thread time and often flashes a layout shift. The usual causes are non-deterministic values rendered during the build: Date.now(), Math.random(), locale-dependent formatting, or browser-only APIs like window and document read during render.
Keep server output deterministic, and guard browser-only access so it runs after hydration rather than during render:
// Read window only after mount, never during the render that the server also runs.
import { useEffect, useState } from 'react';
export default function Width() {
const [w, setW] = useState(null);
useEffect(() => setW(window.innerWidth), []);
return <span>{w ?? '—'}</span>;
}
For components that genuinely cannot render on the server because they depend on the DOM, skip server rendering entirely with client:only="react" (the framework name is required) rather than letting them produce broken server markup.
Production Monitoring
Lab tools like Lighthouse give you Total Blocking Time, a useful proxy, but INP is a field metric — it is measured from real interactions across real devices, and lab and field routinely disagree. Total Blocking Time is a single synthetic load on a throttled CPU; field INP is the 75th-percentile interaction across the full spread of real hardware, background tabs, and cheap Android phones where your hydration cost is multiplied several times over. A page can post a green lab score and still fail INP in the field because the slow devices that dominate the p75 never showed up in the lab run.
Deploy Real User Monitoring to track INP against when each hydration chunk loads, so a slow island shows up as a correlated INP spike. Attribute each interaction to the element and event target that caused it, so you learn which island is spending the main thread rather than just that INP is high. Provide a sensible non-interactive fallback for every island — a rendered-but-inert state — so a slow network doesn't leave a component dead while its bundle is still downloading; the "click does nothing yet" window between paint and hydration is a classic invisible source of bad interactions. The full setup — from the web-vitals library to attributing INP to a specific interaction — is in Measuring INP on Static Sites with Real-User Monitoring.
Common Pitfalls
- Over-hydrating static content: a
client:*directive on presentational markup ships JavaScript for nothing and raises INP. Default to no directive; add one only for genuine interactivity. - Everything on
client:load: even correctly-marked islands hurt INP if they all hydrate eagerly. Move anything not strictly above the fold toclient:idleorclient:visible. - Hydration mismatches: server HTML that differs from the client render (unguarded
Date.now(),window-only code) causes a hydration abort and layout shift. Keep server output deterministic and guard browser-only APIs. - Ignoring third-party scripts: analytics, chat, and ad tags run on the same main thread your interactions need, and they routinely wreck INP on otherwise-fast pages. Budget them as strictly as your own code.
- No CI budget: without a build gate, hydration creep is invisible until it shows up in field INP weeks later.
Key Takeaways
- INP is the one Core Web Vital a static site can still lose, and it is lost on the main thread during interaction.
- Default to zero JavaScript; hydrate only genuine islands with the narrowest
client:*directive that works. - Directive timing is the lever:
client:visibleandclient:idlekeep the early interaction window free,client:loadspends it. - Split and budget the JavaScript that remains, budget per route rather than sitewide, and fail the build when a route exceeds its budget.
- When islands must share state, use a tiny framework-agnostic store or a
CustomEvent— never reintroduce a page-wide runtime to coordinate them. - The technique is generator-agnostic: Eleventy
is-land, Alpine, and scoped Hugo scripts win INP the same way Astro directives do. - Keep server and client renders deterministic to avoid mismatch cost, and confirm the result with field INP, not just lab Total Blocking Time.
FAQ
What is partial hydration and why does it matter for INP?
Partial hydration means shipping a page as static HTML and attaching JavaScript only to the components that are genuinely interactive, instead of booting a framework over the whole page. Because Interaction to Next Paint is driven by main-thread work during interaction, hydrating fewer components leaves the main thread free and keeps INP low.
When should I use client:load versus client:visible versus client:idle?
Use client:load only for above-the-fold controls that must respond the instant the page appears, such as a header search box. Use client:visible for anything below the fold so its JavaScript is deferred until the component scrolls near the viewport. Use client:idle for non-urgent widgets that can wait until the main thread is quiet.
Does islands architecture eliminate all JavaScript?
No. It makes JavaScript opt-in rather than default. The static parts of the page ship zero JavaScript, but each interactive island still ships the targeted bundle it needs to hydrate. The win is that you stop paying for a framework runtime on the 90 percent of the page that is static.
How do I stop hydration regressions from shipping?
Make the client JavaScript budget a build gate. Sum the size of the emitted client chunks in CI and fail the build when the total exceeds your per-route budget. Pair that with field Real User Monitoring so a regression that slips past the lab still surfaces in production INP data.
What causes a hydration mismatch and how do I avoid it?
A mismatch happens when the HTML rendered on the server differs from what the component renders on the client, usually because of non-deterministic values like Date.now() or browser-only APIs read during render. Keep server output deterministic and guard window and document access so the client can reuse the server markup instead of throwing it away.
Do non-Astro generators support partial hydration?
Not as a built-in directive system, but you get the same effect by shipping static HTML from Eleventy, Hugo, or Jekyll and attaching a small Alpine.js or vanilla-JavaScript handler only where interactivity is needed. The principle is identical — no global framework runtime, just scoped behavior on specific elements.
Related
- Parent: Performance Optimization & Core Web Vitals for SSGs — where hydration fits the INP picture.
- Astro Islands vs Full Hydration Performance — the main-thread comparison with concrete numbers.
- How to Reduce Bundle Size in Eleventy Builds — trimming JavaScript on a template-only generator.
- Measuring INP on Static Sites with Real-User Monitoring — proving the result with field data.
- Largest Contentful Paint Optimization for Static Sites — the sibling effort that owns the other half of the Core Web Vitals story.
- CDN Caching Rules for SSGs — the sibling effort that owns TTFB and repeat-visit cost.