How to Fix Slow or Dead First Interactions During Hydration
This guide extends Optimizing First Input Delay (FID) and INP within Core Web Vitals & Measurement to the most frustrating interaction a server-rendered page can produce: a user taps a button that is clearly visible, and nothing happens — or it happens a second later.
Server rendering paints content early, which is good for LCP, but the content is inert until the framework hydrates it: downloads the JavaScript, re-creates the component tree in memory, and attaches event handlers. On a mid-tier phone, hydration of a moderately complex page takes 500ms–2s, usually as one long task. An interaction during that window either waits for the task to finish (large input delay, poor INP) or arrives before the handler is attached and is silently dropped (a "dead click" that INP does not even record).
Rapid Diagnosis
- Segment INP by time since load. In RUM, bucket INP by
interactionTimerelative to navigation start. A spike in the first 1–3 seconds that disappears afterwards is a hydration problem. - Reproduce with an early click. In a throttled trace (4x CPU), start recording, reload, and tap a button as soon as it appears. The interaction's input delay should equal the remaining hydration time.
- Check for dead clicks. Add a temporary capture-phase listener on
documentthat logs clicks; compare with clicks that reached component handlers. Clicks in the first gap are dropped. - Find the hydration task. In the Performance panel, the long task right after the framework bundle evaluates, with call stacks into
hydrateRoot,createApp().mountor the framework's hydrate function. - Measure hydration duration. Add
performance.mark('hydrated')in the root component's mount effect and read the time.
Root Cause Analysis
1. Monolithic hydration. The default hydration path processes the whole tree in one synchronous task. Nothing else, including input, can run until it completes.
2. Handlers attached at the end. Event handlers become live only when their component hydrates. Before that, the DOM is visible but inert.
3. Too much JavaScript before hydration starts. Hydration cannot begin until the bundle is downloaded and evaluated; large bundles extend the inert window before the long task even starts.
4. Third-party scripts competing during hydration. Tags firing at DOMContentLoaded or load interleave with or extend the hydration window.
Step-by-Step Resolution
1. Measure early-interaction INP separately
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
const hydratedAt = performance.getEntriesByName('hydrated')[0]?.startTime ?? Infinity;
report({ inp: Math.round(value), beforeHydration: attribution.interactionTime < hydratedAt,
inputDelay: Math.round(attribution.inputDelay) });
});
// trade-off: INP reports only the worst interaction, so early interactions are
// only visible when they are the worst. For a complete picture, record every
// interaction's timing with an Event Timing observer in sampled sessions.
Expected outcome: a clear split between interactions before and after hydration, quantifying how much of your INP problem this guide addresses.
2. Use selective hydration so interacted regions hydrate first
React 18+ hydrates Suspense boundaries independently and, if the user interacts with a not-yet-hydrated boundary, prioritises it. Wrap independent page regions in boundaries.
export default function ProductPage() {
return (
<>
<Header />
<Suspense fallback={<GallerySkeleton />}><Gallery /></Suspense>
<Suspense fallback={<BuyBoxSkeleton />}><BuyBox /></Suspense>
<Suspense fallback={<ReviewsSkeleton />}><Reviews /></Suspense>
</>
);
}
// trade-off: React can only prioritise at boundary granularity. A single
// boundary around the whole page gives you nothing; dozens of tiny ones add
// overhead. Aim for one boundary per independently interactive region.
Expected outcome: a tap on the buy box during hydration triggers its boundary first; input delay falls from the whole hydration time to one boundary's time.
3. Hydrate below-the-fold and low-priority components lazily
Defer components that cannot be interacted with immediately until they are visible or the browser is idle. Nuxt (3.16+) supports hydrate-on-visible and hydrate-on-idle on lazy components; Astro uses client:visible and client:idle.
<template>
<ProductGallery /> <!-- hydrate now: above the fold -->
<LazyProductReviews hydrate-on-visible /> <!-- hydrate when scrolled to -->
<LazyNewsletterSignup hydrate-on-idle /> <!-- hydrate when idle -->
</template>
<!-- trade-off: a lazily hydrated component is inert until its trigger. If a
user can reach it quickly (a short page, a fast scroller), the first click
on it is delayed. Do not lazy-hydrate primary calls to action. -->
Expected outcome: the startup hydration task shrinks to the above-the-fold components, typically by 40–70%.
4. Capture and replay early events
Where the framework supports it, enable event replay so clicks during hydration are recorded and dispatched once handlers are attached, instead of being dropped. Angular provides withEventReplay(); React replays discrete events for Suspense boundaries automatically. For other setups, a tiny inline script can capture clicks on marked elements and re-dispatch them.
<script>
// Inline in <head>: queue clicks on critical controls until the app is ready.
window.__early = [];
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-replay]');
if (t && !window.__hydrated) { e.preventDefault(); window.__early.push(t); }
}, true);
</script>
<!-- After hydration: window.__hydrated = true; window.__early.forEach(el => el.click()); -->
<!-- trade-off: replayed clicks fire after a delay, so the INP for that
interaction still includes the wait. Replay prevents lost clicks; it does
not make them fast. Shrinking hydration is still the primary fix. -->
Expected outcome: no dead clicks on critical controls; support tickets about "button doesn't work" on slow devices disappear.
Verification
In a throttled trace, tap the primary button immediately after it paints. The interaction's input delay should be under 100ms, and the longest task during startup should be one boundary's hydration rather than the whole tree. In RUM, the "before hydration" INP bucket should converge towards the "after" bucket. Track hydration duration itself as a custom metric, with a budget per template.
Reducing What Needs Hydrating in the First Place
Every technique above manages the cost of hydration; the most durable fix is to need less of it. Audit what is genuinely interactive on each template. Content sites often hydrate entire article bodies, navigation menus that could be CSS-only, and footers with no behaviour at all. Server Components in React, islands in Astro, and <NuxtIsland> in Nuxt let you render those parts on the server with zero client JavaScript. On a typical marketing page, the interactive surface is a menu, a form and perhaps a carousel — a small fraction of the DOM. Hydrating only that fraction reduces the startup task by an order of magnitude, which no amount of chunking can match. Islands vs full hydration for content sites quantifies the difference.
Measuring Hydration as a First-Class Metric
Hydration duration is rarely tracked, yet it determines the length of the window in which interactions are slow or lost. Instrument it explicitly: mark the start when the framework bundle begins executing and the end in the root component's mount hook, then send the difference with your RUM beacon alongside device class. Chart the p75 per template.
Two derived numbers are particularly useful. The first is the inert window — from first contentful paint to hydration end — which is how long users can see controls that do not work. The second is the share of sessions with an interaction inside the inert window, which tells you how many users actually experience the problem. A long inert window on a page where nobody interacts for five seconds matters little; a short one on a page where users immediately tap a search box matters a lot.
FAQ
Can I show a visual "not ready" state until hydration finishes?
You can style controls as disabled until hydration, but it trades one problem for another: users see a disabled page that looks broken on slow devices. Prefer making controls work early — selective hydration, replay, or plain HTML forms that function without JavaScript — over signalling that they do not.
Does a progressive-enhancement form avoid the problem?
Yes, for that control. A real <form> with an action submits without JavaScript, so an early tap works — as a full navigation rather than an in-app transition. Once hydrated, the handler can intercept the submit for a smoother experience. It is the most robust pattern for critical actions like search and add-to-cart.
Related
- Hydration & partial rehydration strategies — the architectural options in depth.
- Reducing Total Blocking Time in Lighthouse — the lab metric that tracks the same startup work.
- Angular SSR hydration and event replay — framework support for replaying early events.