React Hydration: You Don't Need to Wake Every Component at Once — How to Improve TTI with MRAH in Next.js
"Why is it so slow?" That was the first feedback I got after switching to SSR. FCP (First Contentful Paint) had clearly improved, but users said the screen would appear and buttons wouldn't respond to clicks at all. The culprit was hydration. The moment the server-rendered HTML arrives in the browser, React starts attaching event listeners on top of it. The problem is that it processes everything simultaneously — whether it's the critical "Buy" button or a footer widget nobody scrolls to. The main thread gets blocked, TTI (Time to Interactive) skyrockets, and users are left staring blankly at the screen.
MRAH (Modular Rendering and Adaptive Hydration) is an architectural pattern that dynamically determines the timing and order of hydration based on a component's importance, visibility, and device environment. Formally defined in April 2025 on arXiv, the core of this pattern is simple: measure what matters first, separate the screen into independent modules, and defer what's less important. These three steps run throughout this entire article.
This article is aimed at those already using React/Next.js. If concepts like Suspense, React.lazy(), and hydrateRoot aren't unfamiliar to you, you can follow along right away. By the end, you'll walk away with three patterns you can apply directly to your Next.js project.
Core Concepts
Why Hydration Becomes a Bottleneck
I also thought at first, "Doesn't SSR just make things fast?" FCP does improve. But TTI is a completely different story. The browser receives the HTML and renders it to the screen almost instantly, but until React finishes hydrating on top of it, user clicks are ignored.
Traditional hydration works like this:
// Hydrating the entire root component all at once
hydrateRoot(document.getElementById('root'), <App />);
// → Navigation, main content, recommendation widget, comments, footer — all processed simultaneouslyThe navigation dropdown and the "Related Articles" slider at the very bottom of the page occupy the main thread at the same priority. Honestly, 80% of users will never even scroll to that slider.
This is exactly the gap between FCP and TTI. FCP is the moment the first content is painted on screen, and TTI is the moment the user can actually interact. SSR moves FCP earlier, but without optimizing hydration, the improvement to TTI is limited.
Modular Rendering — Splitting the Page into Independent Islands
The first principle of MRAH is separating the page into units (modules) that can be independently rendered and hydrated. Inspired by Astro's Islands Architecture, the core idea is simple: on a sea of static HTML, only the interactive "islands" are activated with JavaScript, while everything outside the islands stays as pure HTML, eliminating unnecessary JS execution at the source.
In the React ecosystem, React.lazy() and Suspense play this role.
import { lazy, Suspense } from 'react';
// Split non-critical sections into independent chunks
const ReviewSection = lazy(() => import('./ReviewSection'));
const RecommendedProducts = lazy(() => import('./RecommendedProducts'));
const Footer = lazy(() => import('./Footer'));
function ProductPage() {
return (
<>
{/* HeroSection is directly imported without lazy() — needs immediate rendering */}
<HeroSection />
{/* Each module loads and hydrates independently */}
<Suspense fallback={<ReviewSkeleton />}>
<ReviewSection />
</Suspense>
<Suspense fallback={<ProductSkeleton />}>
<RecommendedProducts />
</Suspense>
<Suspense fallback={null}>
<Footer />
</Suspense>
</>
);
}Note that HeroSection is not wrapped with lazy(). Applying lazy() to an area that's needed immediately will actually delay loading and produce the opposite effect. Code splitting is a tool for "things needed later."
Adaptive Hydration — Reading the Context and Setting the Order
The second principle is what distinguishes MRAH from simple code splitting. Adaptive hydration reads four signals to dynamically determine when to hydrate:
| Signal | Detection Method | How It's Used |
|---|---|---|
| Visibility | IntersectionObserver |
Hold hydration until the component enters the viewport |
| Idle Time | requestIdleCallback |
Handle non-urgent components when the browser is idle |
| Network | Network Information API | Skip hydrating non-critical widgets on 2G/3G connections |
| Device | navigator.hardwareConcurrency |
Defer heavy tasks on low-end devices |
Translating the basic pattern of detecting viewport entry to trigger hydration into code looks like this:
import { useEffect, useRef, useState } from 'react';
function useVisibilityHydration() {
const ref = useRef<HTMLDivElement>(null);
const [shouldHydrate, setShouldHydrate] = useState(false);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldHydrate(true);
observer.disconnect(); // Disconnect once it enters
}
},
{ rootMargin: '200px' } // Start preparing 200px before the viewport
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return { ref, shouldHydrate };
}This hook is used directly in the practical examples below.
Practical Application
Example 1: E-commerce Product Page — Wake the Buy Button First
This is a situation you frequently encounter in real-world work. A product detail page has both an "Add to Cart" button and a reviews section. The buy button needs to work the moment the user lands on the page, but the reviews are in an area the user has to scroll to see.
Using the react-lazy-hydration library, you can declaratively handle viewport-entry-based and browser-idle-based hydration.
import dynamic from 'next/dynamic';
import LazyHydrate from 'react-lazy-hydration';
// next/dynamic: splits the bundle into a separate chunk (handles code splitting)
// LazyHydrate: controls when to hydrate the loaded component
// The two roles are different, so using them together works without conflict.
const ReviewSection = dynamic(() => import('./ReviewSection'), {
// ssr defaults to true — preserves the SSR HTML as-is
loading: () => <ReviewSkeleton />,
});
const RecommendedSlider = dynamic(() => import('./RecommendedSlider'), {
loading: () => <SliderSkeleton />,
});
const Footer = dynamic(() => import('./Footer'), {
loading: () => null,
});
export default function ProductPage() {
return (
<main>
{/* Immediate hydration — core area of the purchase flow */}
<ProductHero />
<AddToCartButton />
{/* Hydrate when visible in the viewport */}
<LazyHydrate whenVisible>
<ReviewSection />
</LazyHydrate>
<LazyHydrate whenVisible>
<RecommendedSlider />
</LazyHydrate>
{/* Hydrate when the browser is idle */}
<LazyHydrate whenIdle>
<Footer />
</LazyHydrate>
</main>
);
}| Area | Hydration Strategy | Reason |
|---|---|---|
ProductHero / AddToCartButton |
Immediate | Directly tied to purchase conversion, cannot be delayed |
ReviewSection |
whenVisible |
Not visible without scrolling |
RecommendedSlider |
whenVisible |
Lower priority than the core purchase flow |
Footer |
whenIdle |
Minimal interaction potential, process when idle |
Example 2: Adaptive Hook That Reads Device and Network Environment
If you want to change the hydration strategy itself based on network status or device performance, a clean pattern is consolidating the conditions into a custom hook.
One important note: navigator.connection (Network Information API) is currently only supported in Chrome and Edge — it does not work in Firefox or Safari. navigator.hardwareConcurrency can also be intentionally limited in Firefox's privacy mode. If you want to use this in production, defensive code is absolutely required.
// Type declaration for safely using the Network Information API in TypeScript strict mode
type NetworkInformation = {
effectiveType?: '2g' | '3g' | '4g' | 'slow-2g';
};
function useAdaptiveHydration() {
const nav = navigator as Navigator & { connection?: NetworkInformation };
const connection = nav.connection;
// Firefox/Safari: connection is undefined — treat as fast network if absent
const isSlowNetwork =
connection?.effectiveType === '2g' ||
connection?.effectiveType === 'slow-2g';
// Detect low-end devices by logical core count (may be limited in privacy mode)
const isLowEndDevice = (navigator?.hardwareConcurrency ?? 4) <= 2;
return {
shouldDefer: isSlowNetwork || isLowEndDevice,
isSlowNetwork,
isLowEndDevice,
};
}
function HeavyWidget() {
const { shouldDefer } = useAdaptiveHydration();
if (shouldDefer) {
// Low-end environment: render static HTML only, skip JS hydration
return <StaticFallback />;
}
return <FullInteractiveWidget />;
}You can also use it with startTransition to delegate non-urgent hydration to React's scheduler. Combined with the visibility detection pattern from earlier, it looks like this:
import { startTransition, useState, useEffect, useRef } from 'react';
// Visibility trigger component wrapping IntersectionObserver
function VisibilityTrigger({
onVisible,
children,
}: {
onVisible: () => void;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onVisible();
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [onVisible]);
return <div ref={ref}>{children}</div>;
}
function ProductPage() {
const [showReviews, setShowReviews] = useState(false);
const handleReviewsVisible = () => {
// Mark review hydration as a non-urgent task
startTransition(() => {
setShowReviews(true);
});
};
return (
<>
<ProductHero />
<VisibilityTrigger onVisible={handleReviewsVisible}>
{showReviews ? <ReviewSection /> : <ReviewSkeleton />}
</VisibilityTrigger>
</>
);
}Wrapping with startTransition means that even while review hydration is in progress, if the user clicks a button, React can respond immediately. This has a direct impact on improving INP (Interaction to Next Paint).
Pros and Cons Analysis
Advantages
When I first applied this pattern, I experienced the "feel" changing before the numbers did. The buy button started responding instantly, and teammates said "it's definitely faster" before they even looked at the Lighthouse score.
| Item | Details |
|---|---|
| Reduced JavaScript payload | Code loads only when needed, significantly reducing initial bundle size |
| Improved TTI | Only core components hydrate first, making the user experience more responsive |
| Reduced TBT (Total Blocking Time) | Distributing hydration reduces main thread blocking |
| SEO preserved | SSR HTML is still served as-is, so search engine crawling is unaffected |
| Low-end and slow-connection environments | Adjusting the scope of hydration based on network and device conditions improves practical usability |
For reference, figures sometimes cited in articles — "82% payload reduction," "62% TTI improvement" — are experimental results reported in the arXiv paper. Results can vary significantly depending on project size and structure, so it's recommended to use your own Lighthouse Before/After measurements as the basis for adoption.
Disadvantages and Caveats
I personally once lost half a day to a global Context issue. A child module tried to read user information from the authentication state Context before it had hydrated, causing errors, and it took quite a while to find the root cause. The caveats below come from that experience.
| Item | Details | Mitigation |
|---|---|---|
| Design complexity | You must manually design which components hydrate when | Recommend formalizing priority criteria in team documentation |
| Global state sharing | Shared state like auth and cart can conflict with hydration order | Better to minimize dependencies with atomic state managers like Zustand or Jotai |
| Browser compatibility | Network Information API is unsupported in Firefox/Safari; hardwareConcurrency may be limited in privacy mode |
Always write defensive code (?? defaultValue) |
| Runtime condition uncertainty | Optimal strategies differ per device and network, making generalization difficult | Recommend setting a minimum threshold and gradually refining conditions |
| E2E test complexity | Component behavior varies depending on hydration timing | Patterns like Playwright's waitForSelector to explicitly await hydration completion are useful |
| Initial refactoring cost | Existing monolithic structures need to be split into module units | Apply incrementally per page; avoid switching everything at once |
| Lack of validation data | The official paper also lists large-scale performance evaluation as "future work" | Recommend measuring with Lighthouse and WebPageTest directly before deciding to adopt |
The Most Common Mistakes in Practice
1. Applying whenIdle to every component
The approach of "deferral is good, so defer everything" feels very tempting at first. When I first tried react-lazy-hydration, I sprinkled whenIdle everywhere, then hurriedly reverted when I noticed the "Like" button was also coming alive a beat too late. It's important to define priority criteria first before deciding the scope of application.
2. Child modules trying to read from global Context before it has hydrated
Global Context Providers like auth state and theme should always be placed in the immediate hydration zone, with modules deferred beneath them. If a child tries to read a value while the Context isn't ready, unexpected runtime errors will occur.
3. Attempting to detect network conditions during the SSR phase
navigator.connection is a client-only API. In an SSR environment (the server), navigator itself doesn't exist, so it must only be used inside useEffect or client components.
Closing Thoughts
Rather than applying this to production all at once, it's better to start with your single heaviest page.
- Measure your bottleneck page first. Record your TBT and TTI figures using the Chrome DevTools Performance tab or
npx lighthouse <URL> --view. These numbers are your pre-adoption baseline. - Split non-critical components with
next/dynamic. Start by converting sections below the viewport (reviews, recommended products, footer) to dynamic imports. The SSR HTML is preserved while the client JS is lazy-loaded. - Install
react-lazy-hydrationand applywhenVisible/whenIdle. One line —pnpm add react-lazy-hydration— and you're ready. Then compare against the metrics you measured in step 1 to see the effect directly.
If you received "why is it still slow" feedback when you first introduced SSR, you can now go back with the answer. The problem wasn't SSR — it was the order of hydration, and the solution is to measure, separate, and defer. Combining this with React Server Components lets you reduce client-side JavaScript even more aggressively, but that's a topic for another time.
Glossary
| Term | Description |
|---|---|
| FCP (First Contentful Paint) | The moment the browser paints the first content to the screen. SSR moves this value earlier |
| TTI (Time to Interactive) | The moment the user can actually interact. SSR moves FCP earlier, but hydration determines TTI |
| INP (Interaction to Next Paint) | An official Core Web Vitals metric since 2024. The time from a user input (click, keypress) until the browser paints the next frame. When hydration occupies the main thread, INP spikes |
| TBT (Total Blocking Time) | The sum of time the main thread was blocked for 50ms or more between FCP and TTI. The longer hydration takes, the higher TBT grows |
| startTransition | An API introduced in React 18. Marks a state update as "non-urgent" so urgent tasks like user input can be processed first |
| Islands Architecture | A pattern where only the interactive "islands" on a sea of static HTML are activated with JavaScript. Astro is the representative example and influenced MRAH's modular rendering concept |
References
- Improving Front-end Performance through MRAH in React Applications | arXiv:2504.03884
- MRAH Paper HTML Version | arXiv
- MRAH Architecture — Milad Abbasi | Medium
- Modular Rendering & Adaptive Hydration: Frontend's New Performance Frontier (2025) | JavaScript in Plain English
- Adaptive Hydration and Modular Rendering in React and Next.js | JavaScript in Plain English
- Supercharging React Apps: Strategic Hydration for Lightning-Fast Performance | Devmap/Medium
- Performance-First React: Adaptive Hydration & Modular Rendering Explained | Medium
- MRAH Paper | TechRxiv
- react-lazy-hydration | GitHub
- Server Components vs. Islands Architecture | LogRocket Blog
- Advanced SSR 2025: Selective Hydration, RSCs, and Edge Rendering | madrigan.com