INP Regression from React 19 Activity Component on Mobile

React 19 Activity component spiking INP past 300ms on mobile after a click? Here is the synchronous commit that triggers the long task and the fix that holds.
INP Regression from React 19 Activity Component on Mobile
INPReact 19Core Web Vitals
May 29, 20266 min read1132 words

The Problem

Pushed a React 19.2 upgrade to a client's marketing site last week and the next morning CrUX showed INP slipping from a healthy 180ms to 340ms on mobile. Lab data on PageSpeed Insights agreed. The desktop score barely moved. Lighthouse traces showed the offender clearly: a single 280ms long task firing on click, blocking the interaction from painting any visual feedback.

The site uses the new <Activity> component to keep the second tab of a product configurator mounted but hidden when the user is on tab one. The idea was to make tab switches instant. They were instant in dev. They were a disaster in production on a mid-range Android.

The component tree under Activity is not small — a 3D-ish product preview, a price calculator with about thirty inputs, and a sticky CTA that does its own intersection observer. None of that runs while Activity is hidden. The bug is what happens the instant you make it visible.

If you have shipped Activity in production and your INP dropped overnight on mobile, this is almost certainly your post.

Why It Happens

React 19 Activity has two modes, visible and hidden. While hidden, React pauses effects and skips committing rerenders to that subtree. State is preserved. Effects are queued. The subtree exists in the fiber tree but does not run. The framing in the Activity RFC is that you trade memory for instant reactivation. The catch is in the word "instant".

When you flip Activity from hidden to visible, React commits everything that was queued during the hidden period in a single synchronous pass. Effects that ran once before hiding run again. Suspense boundaries that resolved while hidden replay their fallbacks. The reconciler does not get to break this work across frames because the visibility change is a synchronous prop update on the parent. On a desktop with a fast CPU you do not feel it. On a 2022 mid-range Android with four small cores, a configurator with thirty controlled inputs becomes a 250-300ms long task on the main thread.

The Long Animation Frames API picks this up as a single LoAF entry with the entire activation work attributed to a synchronous script block. INP measures from the user's tap to the next paint that shows visual response, and the long task lives in exactly that window. The result is a clean linear regression on the INP field data the moment you ship.

React.memo does not help because the subtree was already memoised. It is the commit pass itself that takes the time. useDeferredValue on individual inputs does not help either, because the activation runs at a higher priority than your transitions.

The Fix

There are three things to do, in order of impact. Most sites only need the first two.

Step 1: Move the reactivation into a transition. The visibility prop change has to come from startTransition so React schedules the commit at transition priority instead of synchronous. This alone takes a 280ms long task down to two or three 50ms-ish tasks that yield to paint between them:

'use client'

import { Activity, startTransition, useState } from 'react'

export function ProductTabs() {
  const [active, setActive] = useState<'overview' | 'configure'>('overview')

  function selectTab(next: 'overview' | 'configure') {
    startTransition(() => {
      setActive(next)
    })
  }

  return (
    <>
      <TabBar active={active} onSelect={selectTab} />
      <Activity mode={active === 'overview' ? 'visible' : 'hidden'}>
        <Overview />
      </Activity>
      <Activity mode={active === 'configure' ? 'visible' : 'hidden'}>
        <Configurator />
      </Activity>
    </>
  )
}

The visual feedback (tab indicator moving, button highlighting) paints inside the synchronous slice. The heavy work runs in transition slices that yield. INP measures the first paint, which now lands inside 120ms instead of 340ms.

Step 2: Wrap the heavy subtree in Suspense with lazy boundaries. Activity activation runs all queued effects in the subtree synchronously. If the subtree is lazy() and not yet resolved at activation time, React commits a fallback (which is cheap) and resolves the real component on the next frame. This breaks the activation work across at least two frames automatically:

import { Activity, Suspense, lazy } from 'react'

const Configurator = lazy(() => import('./Configurator'))

<Activity mode={active === 'configure' ? 'visible' : 'hidden'}>
  <Suspense fallback={<ConfiguratorSkeleton />}>
    <Configurator />
  </Suspense>
</Activity>

The first activation pays the import cost (which is fine because the user just clicked). Subsequent activations skip straight to the resolved module because the lazy promise has already settled. Either way, the commit pass is split.

Step 3: Make controlled-input arrays uncontrolled-with-defaults. Activity replays the effect chain on activation, and every useEffect that touches a state setter for a controlled input contributes to the long task. For form-heavy configurators, switch the thirty controlled inputs to uncontrolled <input defaultValue={...}> with a single form-level read in the submit handler. This reduces the effect queue by an order of magnitude:

function PriceField({ name, initial }: { name: string; initial: number }) {
  return <input name={name} defaultValue={initial} type="number" />
}

This is the move I made the loudest INP gain from on the client site. Activity activation cost dropped from 280ms to about 60ms once the controlled-input fan-out was gone.

You can verify each fix in isolation with the Performance panel's INP audit, or in field data with the PerformanceObserver for event entries. Mobile-only field data is the source of truth here. Lab numbers under-report Activity cost because lab CPUs are too fast to feel it.

The Lesson

Activity keeps state cheap but makes activation expensive on slow CPUs. The synchronous prop flip pays for itself the moment you tap into a heavy subtree. Wrap the visibility change in startTransition, lazy-load the heavy children behind Suspense, and replace controlled inputs with uncontrolled ones where you can. INP comes back down to where it was before the upgrade, and you keep the instant tab switch in user perception.

If your INP field data took a hit after a React 19 upgrade and your team cannot pin it on a specific component, that is the kind of Core Web Vitals regression I trace for clients. See my services. For a related INP regression I traced last week, read INP regression from Sentry session replay on mobile.

Activity component costing you Core Web Vitals? Let me trace it for you.

Back to blogStart a project