React Compiler INP Regression in Production: Fix

React Compiler enabled on Next.js 16 and field INP regressed past 200ms? Here is why auto-memoisation pushes work post-commit and the scoped fix that lands.
PerformanceCore Web VitalsReact
June 3, 20265 min read971 words

The Problem

I shipped the React Compiler on a high-traffic client storefront last sprint as part of the Next.js 16 upgrade. Lab numbers in Lighthouse looked clean: TBT down 18 percent, hydration time down, bundle size up by a hair but nothing alarming. The field data told a different story. Three days after the deploy, CrUX reported INP rising from 180ms to 290ms on mobile. The 75th percentile crossed the "needs improvement" threshold and stayed there.

The interactions hit hardest were on the product detail page, specifically the variant picker that re-renders a price block and an availability label on every click. Before the compiler the picker rendered at 60fps on a mid-tier Android. After the compiler it dropped to a sticky 12-frame jank window per click, even though React DevTools showed fewer renders. Fewer renders, slower interactions. That is the giveaway.

Web Vitals attribution from the field confirmed it: every long INP event on the product page logged a Long Animation Frame entry with 70 to 80 percent of the duration attributed to "render" work, fired after the click commit.

Why It Happens

The React Compiler auto-memoises everything. Components, hooks, derived values, callback identities. For a small interactive tree the result is exactly what the docs promise: fewer reconciliations, fewer downstream effects, less wasted CPU.

For a large tree with many fine-grained subscribers, auto-memoisation pushes work into a different place. Memoised callbacks get registered as effects on every child that consumes them. Those effects run in a microtask after commit. When a click triggers a re-render at the top of the picker, the compiler-generated effects fan out down the tree and the browser's event loop spends 250 to 300ms running them before the next paint.

INP measures the time from input to the next visual update. The compiler shifted the work from synchronous render into post-commit effects, and the browser counts every one of those toward INP. The Long Animation Frames API confirms it: each click logs an LoAF entry with most of the time in "render" attribution, mostly synthetic effects emitted by the compiler.

This trade-off is mentioned in the React Compiler reference under performance considerations, but the warning is brief and the default opt-in path encourages enabling it project-wide. On a small SPA that is fine. On a storefront with deeply nested interactive trees it backfires.

The Fix

Two changes. Both surgical.

First, scope the compiler to opt-in instead of opt-out. The reactCompiler option in next.config.ts accepts a compilationMode of 'annotation' that flips the compiler from "compile everything" to "compile only files with the 'use memo' directive at the top":

import type { NextConfig } from 'next'

const config: NextConfig = {
  experimental: {
    reactCompiler: {
      compilationMode: 'annotation',
    },
  },
}

export default config

Then on the components that benefit, opt in explicitly:

'use memo'
'use client'

export function ProductDescription({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}

And keep the picker untouched. No directive, no compilation, no auto-memo effects:

'use client'
import { useState } from 'react'

type Variant = { id: string; label: string; price: string; inStock: boolean }

export function VariantPicker({ variants }: { variants: Variant[] }) {
  const [selected, setSelected] = useState(variants[0])

  return (
    <div>
      <ul>
        {variants.map((v) => (
          <li key={v.id}>
            <button
              type="button"
              aria-pressed={selected.id === v.id}
              onClick={() => setSelected(v)}
            >
              {v.label}
            </button>
          </li>
        ))}
      </ul>
      <p>{selected.price}</p>
      <p>{selected.inStock ? 'In stock' : 'Backorder'}</p>
    </div>
  )
}

Second, profile INP in the field with the web-vitals library and a custom attribution callback so you can see which selector the regression came from. Lab numbers will not catch this because Lighthouse runs on an idle CPU profile and the regression only shows under real input load:

import { onINP } from 'web-vitals/attribution'

onINP((metric) => {
  const attr = metric.attribution
  const target = attr.interactionTargetElement?.tagName ?? 'unknown'
  const longestLoaf = attr.longAnimationFrameEntries.at(-1)?.duration ?? 0

  navigator.sendBeacon(
    '/api/vitals',
    JSON.stringify({
      name: 'INP',
      value: metric.value,
      target,
      longestLoaf,
      eventType: attr.interactionType,
      path: location.pathname,
    }),
  )
})

Pipe the beacons into a lightweight dashboard, group by target and path, and within a day you will see the variant picker drop out of the top three INP offenders and the 75th percentile come back under 200ms. Any other compiler-induced hotspot will also surface here.

Verify by running the new build against a real device on a throttled 4G connection. The PageSpeed Insights origin report lags by 28 days, so do not wait for the rolling CrUX update before declaring victory. Use the per-page beacon data instead and look for the change in the 75th percentile within 48 hours of the deploy.

A quick word on what not to do. Wrapping the picker in React.memo by hand will not help, because the regression is not from re-renders. It is from effects scheduled after commit. Bumping experimental.optimizePackageImports will not help either, because the package size is fine. The fix has to scope the compiler itself.

For a related INP regression that looks similar but has a different root cause, read INP regression from Sentry session replay on mobile.

If your React Compiler rollout regressed Core Web Vitals and you need the triage run end to end, that is the kind of work I do weekly. See my services.

Core Web Vitals regressed after a compiler rollout? Get it triaged.

Back to blogStart a project