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).

A tap that lands during hydration Timeline showing content painted at 1.2 seconds, hydration running until 2.4 seconds, and a tap at 1.6 seconds that waits until hydration finishes. A tap that lands during hydration Paint HTML + CSS Main thread hydration (one task) Tap waits 850ms 0ms 400ms 800ms 1200ms 1600ms 2000ms 2400ms 2800ms The user sees a ready page from 1.2s, but the button cannot respond until hydration ends at 2.4s.

Rapid Diagnosis

  • Segment INP by time since load. In RUM, bucket INP by interactionTime relative 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 document that 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().mount or 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.

Hydration strategies and early-interaction behaviour Comparison of hydration strategies by whether early clicks are delayed, dropped or replayed, and how long the main thread is blocked. Hydration strategies and early-interaction behaviour Strategy Early click Longest task Full synchronous hydration delayed or dropped whole tree Selective hydration (React 18) prioritised boundary per boundary Islands / partial hydration only islands block per island Resumability (Qwik) handler loaded on demand none at startup Event replay (Angular, React) captured and replayed depends on strategy

Step-by-Step Resolution

1. Measure early-interaction INP separately

javascript
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.

jsx
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.

vue
<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.

html
<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.

Startup hydration task by strategy (mid-tier phone) Bar chart of the longest startup task for a product page under four hydration strategies, against the 50 millisecond long-task line. Startup hydration task by strategy (mid-tier phone) Full synchronous hydration 940ms Selective (4 boundaries) 310ms + lazy below-the-fold 180ms Islands only 45ms 50ms long task

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.

/html>