Rendering
Partial Hydration
Hydrate only what needs interactivity
Advanced
hydration performance ssr optimization
Definition
Partial hydration is an optimization technique where only specific parts of a server-rendered page are hydrated with JavaScript, while the rest remains static HTML. This significantly reduces the amount of JavaScript shipped to the browser and improves initial page load performance.
The Problem
// Traditional SSR + Hydration
// Entire app hydrates even if most of it is static
function Page() {
return (
<div>
<Header /> <!-- Needs JS -->
<StaticContent /> <!-- No JS needed! -->
<StaticSidebar /> <!-- No JS needed! -->
<InteractiveWidget /> <!-- Needs JS -->
<Footer /> <!-- No JS needed! -->
</div>
);
}
// Result: 100KB+ JavaScript for entire page
Partial Hydration Solution
// Only hydrate interactive components
function Page() {
return (
<div>
<Header /> <!-- Hydrated -->
<StaticContent /> <!-- Static HTML only -->
<StaticSidebar /> <!-- Static HTML only -->
<InteractiveWidget /> <!-- Hydrated -->
<Footer /> <!-- Static HTML only -->
</div>
);
}
// Result: 20KB JavaScript only for interactive parts
Implementation Approaches
1. React Server Components (Next.js App Router)
// Server Component (default) - No hydration
async function BlogPost({ id }) {
const post = await getPost(id); // Server-side fetch
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<!-- Static, no JS shipped -->
<InteractiveComments postId={id} />
</article>
);
}
// Client Component - Hydrated
'use client';
function InteractiveComments({ postId }) {
const [comments, setComments] = useState([]);
useEffect(() => {
fetchComments(postId).then(setComments);
}, [postId]);
return <CommentList comments={comments} />;
}
2. Astro Framework
---
// Server-side code (never sent to client)
const posts = await fetch('https://api.example.com/posts');
---
<!-- Static HTML -->
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li>{post.title}</li>
))}
</ul>
<!-- Hydrated component -->
<InteractiveSearch client:load />
<!-- Visible in viewport -->
<ImageCarousel client:visible />
<!-- On media query match -->
<MobileMenu client:media="(max-width: 768px)" />
3. Manual Implementation
// Hydration boundaries
function Page() {
return (
<div>
<!-- Static -->
<header>...</header>
<!-- Hydrated on load -->
<div id="search-root"></div>
<!-- Static -->
<main>...</main>
<!-- Hydrated on interaction -->
<div id="modal-root"></div>
</div>
);
}
// Hydrate specific roots
hydrateRoot(
document.getElementById('search-root'),
<SearchWidget />
);
// Lazy hydration
document.getElementById('modal-trigger')
.addEventListener('click', () => {
import('./Modal').then(({ Modal }) => {
hydrateRoot(
document.getElementById('modal-root'),
<Modal />
);
});
});
Hydration Directives
Astro Directives
<!-- client:load - Hydrate immediately -->
<Counter client:load />
<!-- client:idle - Hydrate when browser idle -->
<Comments client:idle />
<!-- client:visible - Hydrate when scrolled into view -->
<ImageCarousel client:visible />
<!-- client:media - Hydrate on media query match -->
<Sidebar client:media="(min-width: 1024px)" />
<!-- client:only - Skip SSR, client-only -->
<HeavyChart client:only="react" />
Next.js Dynamic Imports
import dynamic from 'next/dynamic';
// No SSR, hydrate on load
const HeavyChart = dynamic(() => import('../components/Chart'), {
ssr: false,
});
// Lazy load when needed
const Modal = dynamic(() => import('../components/Modal'), {
loading: () => <p>Loading...</p>,
});
Performance Benefits
Before (Full Hydration)
HTML: 50KB
JS Bundle: 200KB
Hydration Time: 500ms
Total: 250KB + processing
After (Partial Hydration)
HTML: 50KB
JS Bundle: 30KB (only interactive parts)
Hydration Time: 80ms
Total: 80KB + minimal processing
Use Cases
Content-Heavy Sites
// Blogs, documentation, marketing sites
// Mostly static content with some interactivity
<article>
<!-- Static content -->
<Markdown content={post.body} />
<!-- Interactive parts only -->
<LikeButton client:load postId={post.id} />
<ShareButtons client:visible />
<CommentSection client:idle postId={post.id} />
</article>
E-commerce
// Product pages
// Static info + interactive elements
<div>
<!-- Static -->
<h1>{product.name}</h1>
<img src={product.image} />
<p>{product.description}</p>
<!-- Interactive -->
<AddToCartButton client:load product={product} />
<ColorPicker client:load options={product.colors} />
<ReviewsSection client:visible productId={product.id} />
</div>
Best Practices
1. Start Static
// Default to static
// Only add hydration when needed
// Good
function Header() {
return (
<header>
<a href="/">Logo</a>
<nav>...</nav>
</header>
);
}
// Add 'use client' only when needed
'use client';
function Header() {
const [isOpen, setIsOpen] = useState(false); // Needs hydration
// ...
}
2. Granular Boundaries
// Small, focused interactive components
// Rather than large hydrated sections
<!-- Good -->
<article>
<StaticContent />
<LikeButton client:load />
<ShareDropdown client:load />
</article>
<!-- Bad -->
<article client:load> <!-- Too broad -->
<Content />
<LikeButton />
<ShareDropdown />
</article>
3. Progressive Enhancement
// Works without JavaScript
// Enhanced when JavaScript loads
function LikeButton({ initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<form action="/api/like" method="POST">
<input type="hidden" name="count" value={count} />
<button type="submit" onClick={() => setCount(c => c + 1)}>
❤️ {count}
</button>
</form>
);
}
Key Takeaway
Partial hydration dramatically reduces JavaScript overhead by only hydrating interactive components. Use Server Components by default, add client components only when interactivity is needed, and leverage framework-specific directives to control when hydration occurs. This approach is ideal for content-heavy sites where most of the page is static.