How to Defer Below-the-Fold Hydration with IntersectionObserver
This pattern belongs to Hydration & Partial Rehydration Strategies in JavaScript Bundle Optimization & Code Splitting. Your page hydrates a comment thread, a related-products carousel and a newsletter form that sit two viewports below the fold — all before the user can tap the button at the top. Deferring that work is the highest-yield change available without an architectural migration, and IntersectionObserver is the mechanism.
The rule that makes it safe: defer the JavaScript, never the markup.
Rapid Diagnosis: Find What Is Hydrating Off-Screen
- Record a throttled load and look at what runs after paint. Components whose names you recognise as below-the-fold widgets are candidates.
- Measure their share of the hydration task. If the carousel and comments together account for 40% of it, deferring them is a 40% cut.
- Check what they cost beyond hydration. Many below-the-fold widgets also fetch data or register observers on mount, which is often larger than their hydration cost.
- Confirm the server renders them. If a widget renders nothing server-side, deferring hydration will not help — the fix there is to server-render it or to remove it from the initial HTML entirely.
Root Cause Analysis: Why Naive Deferral Breaks Things
1. Discarding the server markup. The most common implementation renders an empty placeholder until hydration, which collapses the component's box, produces a layout shift, and removes content from the initial HTML. It converts an INP win into a CLS loss.
2. Keyboard and find-in-page bypass scrolling. A user tabbing through the document, or using find-in-page, can reach a deferred component without any intersection ever firing. Without a focus-based trigger the control is inert exactly when someone tries to use it.
3. A root margin that is too small. With rootMargin: 0px, hydration begins as the component enters view, so the user sees a moment of unresponsiveness. A band of one to two hundred pixels makes the work finish before arrival.
4. Observers that never disconnect. Leaving an observer attached after hydration leaks a callback per component and keeps them firing on every scroll — a small cost that becomes visible on long pages with many deferred blocks.
Step-by-Step Resolution
1. Build a boundary that preserves the server HTML. The key is to keep the existing DOM and only attach behaviour later.
// React: keep server markup, attach interactivity on approach.
function LazyHydrate({ children, margin = '200px' }) {
const ref = useRef(null);
const [hydrated, setHydrated] = useState(() => typeof window === 'undefined');
useEffect(() => {
if (hydrated) return;
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { setHydrated(true); io.disconnect(); }
}, { rootMargin: margin });
io.observe(el);
return () => io.disconnect(); // no leaked observers
}, [hydrated, margin]);
if (hydrated) return <div ref={ref}>{children}</div>;
// Not yet hydrated: keep whatever the server painted, untouched.
return <div ref={ref} suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: ref.current?.innerHTML ?? '' }} />;
}
// trade-off: reading innerHTML at render time is a hydration-specific hack that
// depends on the server output already being in the DOM. Frameworks with a
// built-in lazy-hydration primitive (Nuxt, Astro, Qwik) express this natively —
// prefer theirs when you have one.
Expected outcome: the component's markup and box are unchanged; only its JavaScript execution moves later.
2. Add the escape hatches. Intersection is not the only way a user reaches a component.
// Force hydration for keyboard users, find-in-page, print and hash links.
function armFallbacks(el, hydrate) {
el.addEventListener('focusin', hydrate, { once: true });
addEventListener('beforeprint', hydrate, { once: true });
if (location.hash && el.querySelector(location.hash)) hydrate();
}
// trade-off: focusin only fires for focusable descendants. A component whose
// only interactive affordance is a click target with no tabindex stays inert —
// which is an accessibility bug in the component, not in the deferral.
Expected outcome: no path to the component leaves it non-functional.
3. Tune the margin against real scroll speed. Start at 200px and check on a mid-tier device: if a fast flick reaches the component before hydration completes, raise it; if you find components hydrating that the user never reaches, lower it. A useful heuristic is roughly one third of the viewport height.
4. Order the work so it cannot pile up. If three deferred components enter the band at once — common on a fast scroll — hydrating all three in one frame recreates the long task you were avoiding.
// Hydrate at most one component per frame, yielding between them.
const pending = [];
let scheduled = false;
function enqueue(fn) {
pending.push(fn);
if (scheduled) return;
scheduled = true;
const run = () => {
const next = pending.shift();
if (next) next();
if (pending.length) requestAnimationFrame(run); else scheduled = false;
};
requestAnimationFrame(run);
}
// trade-off: serialising hydration adds a frame of latency per component when
// several arrive together. That is the right trade — three 30ms tasks with
// gaps beat one 90ms task that blocks a tap.
Expected outcome: deferred hydration never reconstructs a long task, even during fast scrolling.
Verification
Confirm the three things that can go wrong, in order. First, layout stability: scroll slowly past each boundary with the Layout Shift Regions overlay on — nothing should highlight. Second, interactivity: tab through the whole document with JavaScript enabled and confirm every control responds, then repeat using find-in-page to jump directly to a deferred section. Third, the actual saving: re-record the throttled load and compare the hydration task.
// A quick check that deferral is really happening.
performance.getEntriesByType('longtask')
.filter((t) => t.duration > 50)
.forEach((t) => console.log('long task', Math.round(t.duration), 'at',
Math.round(t.startTime)));
// Expect: no long tasks in the window between first paint and first interaction.
In the field, watch INP for early interactions, and watch CLS to confirm the deferral did not introduce shifts. Both should move in the right direction; if CLS worsens, a placeholder is collapsing somewhere and the markup is not being preserved.
FAQ
Does deferring hydration hurt SEO?
No, provided the server markup stays in the document. Crawlers read the rendered HTML, which is complete before any hydration decision is made. The pattern only becomes an SEO problem if the placeholder replaces server content with an empty box — which is also the version that causes layout shift, so the same rule fixes both.
What rootMargin should I use?
Start at 200px and tune with a real device. The goal is that hydration finishes before the component is visible, so the right value depends on scroll velocity and on how long the component takes to hydrate. Roughly a third of the viewport height is a good starting heuristic; anything above a full viewport usually means you are hydrating things the user will never see.
Is this better than just code-splitting the component?
They solve different halves. Code splitting defers the download and parse of the chunk; lazy hydration defers the execution that instantiates the component. Combining them is the strongest option: the chunk loads when the component approaches, and hydration runs immediately after. Splitting alone still leaves the tree walk in the initial hydration task if the component is part of it.
Which components are the best candidates to defer?
Rank by hydration cost multiplied by the probability the user never reaches them. Comment threads, related-content carousels, footers with interactive widgets and newsletter forms score highly on both axes. Anything above the fold scores zero on the second and should stay eager regardless of cost. If a component is expensive and frequently used, deferral only moves the cost — the answer there is to make it cheaper.
Does this work with server components or islands frameworks?
Those architectures already provide it, usually more cleanly. An islands framework's visibility directive is this pattern implemented at the framework level, with the markup preservation handled for you. The hand-rolled boundary here is for applications that hydrate the whole tree and are not migrating — if your framework offers a lazy-hydration primitive, use it rather than reimplementing the mechanics.
How do I test that a deferred component still works?
Exercise all three entry paths in an end-to-end test: scroll to it, tab to it, and jump to it via a hash link. A test that only scrolls will pass while keyboard users find an inert control. It is also worth asserting that the component's markup is present in the server response before any hydration happens, which catches the regression where someone replaces the preserved HTML with an empty placeholder.
Related
- Hydration & Partial Rehydration Strategies — where this pattern fits among the alternatives.
- Islands vs full hydration for content sites — when to stop deferring and change architecture instead.
- Native lazy loading vs IntersectionObserver — the same observer applied to images, with different trade-offs.