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 LCP. 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 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.
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.
- 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.
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 -->
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.
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. Deploy Real User Monitoring to track INP against when each hydration chunk loads, so a slow island shows up as a correlated INP spike. Provide a sensible non-interactive fallback for every island so a slow network doesn't leave a component dead while its bundle is still downloading. 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, and fail the build when a route exceeds its budget.
- 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.
- CDN Caching Rules for SSGs — the sibling effort that owns TTFB and repeat-visit cost.