The Problem
I turned on the React Compiler on a client project two weeks ago after the team finished cleaning up a handful of components that the previous lint run had flagged. The lab numbers in PageSpeed looked fine. The Vercel preview Speed Insights looked fine. After four days of field data the production INP at the 75th percentile climbed from 168ms (good) to 312ms (needs improvement). On mobile it crossed 500ms.
The regression was concentrated on two routes: the product listing page with a faceted filter sidebar, and the search results page with a debounced input. Every other route held steady. The CrUX data caught up over a week and confirmed it was the routes with heavy controlled inputs, not the routes with mostly static content.
Nothing else had changed. No new dependencies, no new components. Reverting just the reactCompiler: true line in next.config.ts and redeploying brought INP back down inside three days as field data refreshed. So the compiler was definitely the cause. The interesting question was why the compiler, which is meant to make things faster, was making interactions slower.
Why It Happens
React Compiler emits memoisation code around every component and every hook output it can analyse. That includes the event handler callbacks you pass to inputs, buttons, and list items. For a static button on a content page, the rewrite is free: the callback is hoisted once and reused. For a controlled input or a faceted filter where every keystroke flows through a chain of useState, useReducer, or a Zustand selector, the rewrite changes how often the callback is rebuilt and how the surrounding components react to it.
Two specific costs showed up in the React DevTools profiler.
The first was the cost of the compiler-generated cache hook itself. Every compiled component runs a small useMemoCache(slots) call at the top of its body. The cost per render is in the low microseconds, but on a list of 60 product cards re-rendering on every keystroke into the filter input, the per-render overhead adds up. The Long Animation Frames API reported each keystroke at around 90 to 110ms of scripting on mid-tier Android devices.
The second was the cost of handler identity. Before the compiler, a child memoised with React.memo re-rendered whenever its parent passed a fresh inline callback. After the compiler, the parent's callback is stable across renders, so children that were doing useless work before now correctly skip, except in the cases where the memoisation cache invalidates because of an upstream context change. When the cart context provider higher up the tree updates, every compiled descendant sees its memo cache invalidate at once. The result: a single tap on the filter sidebar that previously re-rendered 4 cards now re-renders all 60, because the cache invalidation cascades.
Net effect: on routes where the interaction was already cheap, the compiler made the cost neutral or slightly better. On routes with stacked controlled state and broad context fan-out, it pushed the main thread over the 200ms INP boundary.
The React Compiler reference covers the model but does not flag the interaction-cost case explicitly. It assumes the dominant cost in your app is component re-render work, which is true for most apps. It is not true for apps with heavy synchronous state updates per keystroke.
The Fix
Three options, ordered by how aggressive you want to be.
Option A: Opt the heavy routes out with 'use no memo'. This is the lowest-risk fix. Add the directive at the top of the component file for the search input and the filter sidebar:
'use no memo'
import { useState } from 'react'
export function FilterSidebar(props: FilterSidebarProps) {
const [query, setQuery] = useState('')
// ...
}
The directive tells the compiler to skip the file entirely. The component runs as plain React, the way it did before you flipped the flag. INP for that route returns to where it was. Every other route in the app still benefits from compilation.
Audit the entire route's component tree, not just the leaf input. If the input is fine but the list it controls is heavily compiled, the cost shows up in the list. Add the directive to whichever component is hottest in the profiler.
Option B: Switch to annotation mode for the whole app. In next.config.ts:
const nextConfig = {
experimental: {
reactCompiler: {
compilationMode: 'annotation',
},
},
}
With compilationMode: 'annotation', the compiler only touches files that opt in with a 'use memo' directive at the top. The default flips from "compile everything" to "compile nothing". You then walk through the routes that actually benefit from memoisation and add the directive one file at a time, profiling as you go.
This is the right setting for any team that does not have full coverage on interaction performance regression tests. Default-on is too easy to ship blind.
Option C: Profile and fix the underlying re-render fan-out. The compiler is amplifying a pre-existing problem. The cart context provider was at the root of the tree and was updating on every quantity change. Move it down to the component that actually needs the cart, or split it into a state context and a setter context using useMemo. Smaller fan-out, smaller compilation amplification.
This is the cleanest fix but the longest. Run it in parallel with Option A or B so you are not gambling on field data refresh while the work lands.
Verify the rollback with field data, not lab data. PageSpeed Insights lab data does not catch INP regressions reliably because the lab profile does not interact with the page the way real users do. Watch the Web Vitals Real User Monitoring numbers: Vercel Speed Insights, SpeedCurve, or a custom attribution listener on the web-vitals library:
import { onINP } from 'web-vitals/attribution'
onINP((metric) => {
navigator.sendBeacon('/api/vitals', JSON.stringify({
value: metric.value,
target: metric.attribution.interactionTargetElement?.tagName,
eventType: metric.attribution.interactionType,
}))
})
The attribution build of web-vitals reports which element was interacted with and how long the event blocked the main thread. That tells you whether the rollback landed on the right route within hours, not the week CrUX takes to update.
The Lesson
React Compiler reduces re-render work in the common case and amplifies it in the heavy-interaction case. Routes with controlled inputs, broad context providers, and long lists pay an interaction cost that does not show up in lab metrics. Opt heavy routes out with 'use no memo', switch the project to annotation mode by default, and verify with real-user INP attribution rather than PageSpeed. The flag is not a free win.
If your INP cratered after a Next.js 16 upgrade and you want somebody to find the actual long task source instead of guessing, that is the kind of perf audit I run. See my services. For a related INP gotcha on the same routes, read Long Animation Frames API for INP debugging.
INP regressed after a React or Next.js upgrade? Get a perf audit.
