The Problem
Pulled the CrUX report for a client's marketing site this week and INP had jumped from 180ms (Good) to 340ms (Needs Improvement) on mobile in the 28-day window. Desktop was fine. Lighthouse on a throttled 4G profile was clean: INP came out at 90ms. But the field data from real Chrome users on mid-range Androids said otherwise. The first tap on the hero CTA was costing real users a third of a second of unresponsiveness, and bounce rate had crept up two percentage points.
If your mobile INP regressed after a React deploy and Lighthouse will not reproduce it, you are looking at hydration blocking the main thread on slower devices. Synthetic tests miss it; real users feel it.
Why It Happens
React hydration walks the entire server-rendered DOM tree, attaches event listeners, and runs every component's initial render. On a desktop CPU this is 30–50ms. On a 2021 Android with a Snapdragon 695 it is 250–400ms. While that work runs, the main thread is blocked. Any tap during that window queues an input handler that cannot fire until hydration finishes.
INP measures the worst interaction in the session, so a single tap during hydration tanks the score.
Three things make this worse in React 19 + Next.js 16:
Thing 1: Larger client bundles. React 19's compiler injects more memoization helpers per component. The streaming SSR runtime is heavier than the legacy one. The hydration payload on a typical marketing page is now 180–250 KB gzipped vs. 120 KB on React 18.
Thing 2: Accidental client trees. Adding "use client" to a single child forces the entire tree from that node down into the client bundle. One badly-placed directive on a layout component can drag 40 KB of unrelated code into hydration.
Thing 3: Third-party scripts. GTM, Hotjar, Intercom kick off on DOMContentLoaded and compete with React for the main thread. On desktop you have spare cores. On mobile you have one performance core and it gets saturated.
The Fix
Step 1: Measure real INP, not Lighthouse INP. Drop this into a client component in your root layout:
'use client'
import { useEffect } from 'react'
import { onINP } from 'web-vitals/attribution'
export function INPReporter() {
useEffect(() => {
onINP(
(metric) => {
if (metric.value > 200) {
const a = metric.attribution
fetch('/api/vitals', {
method: 'POST',
keepalive: true,
body: JSON.stringify({
value: metric.value,
eventTarget: a.interactionTargetSelector,
eventType: a.interactionType,
inputDelay: a.inputDelay,
processingDuration: a.processingDuration,
presentationDelay: a.presentationDelay,
}),
})
}
},
{ reportAllChanges: true }
)
}, [])
return null
}
The attribution build of web-vitals tells you which DOM element was tapped, which event type, and which sub-phase ate the time. If inputDelay dominates, the main thread was busy (hydration or third-party scripts). If processingDuration dominates, your event handler is too slow. If presentationDelay dominates, the next paint is doing too much.
Step 2: Audit "use client" boundaries. Run this from your repo root to find every client component and how big its tree is:
grep -rn "'use client'" app components --include="*.tsx" --include="*.ts"
For each result, ask: does this component need state, event handlers, or browser APIs? If not, remove the directive. Push "use client" as deep as possible, ideally onto leaf components like a single button or form. Server Components above the boundary add zero hydration cost.
Step 3: Stream non-critical UI behind Suspense. Wrap any below-the-fold component in <Suspense> so it does not block hydration of the hero:
// app/page.tsx
import { Suspense } from 'react'
import Hero from '@/components/Hero'
import TestimonialsSkeleton from '@/components/TestimonialsSkeleton'
import Testimonials from '@/components/Testimonials'
export default function Page() {
return (
<>
<Hero />
<Suspense fallback={<TestimonialsSkeleton />}>
<Testimonials />
</Suspense>
</>
)
}
React hydrates the streamed shell immediately and defers the suspended chunk. The hero becomes interactive before the testimonials section renders. On mobile this typically cuts INP for above-the-fold taps by 100–150ms.
Step 4: Defer third-party scripts past hydration. Use Next.js <Script> with strategy="lazyOnload" for anything that is not critical for the first interaction:
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"
strategy="lazyOnload"
/>
<Script
src="https://static.hotjar.com/c/hotjar-XXXX.js?sv=6"
strategy="lazyOnload"
/>
</body>
</html>
)
}
lazyOnload waits until the browser is idle, after hydration. Analytics still fire, but they no longer steal CPU during the input-likely window. Verify in DevTools Performance with CPU throttling at 4×: the long tasks during hydration should drop noticeably.
Step 5: Trust the field, not the lab. After deploying, watch CrUX for a week via Search Console > Core Web Vitals or PageSpeed Insights. Lighthouse is useful in CI but does not represent your users.
The Lesson
Mobile INP is dominated by main-thread contention during hydration. The fix is less client JavaScript, narrower "use client" boundaries, Suspense below the fold, and deferred third-party scripts. Lighthouse will not show you the win; only field data will.
If your Core Web Vitals are healthy in the lab and broken in the field, that is the kind of performance work I do. See my services, or read the Core Web Vitals 2026 guide for the other field-only regressions to watch.
