The Problem
A client running a Next.js storefront on React 19.2 came to me with a Core Web Vitals report that did not make sense. They had wrapped every filter and sort handler in useTransition to keep the UI responsive during heavy re-renders. Lab tests in Lighthouse looked great. Field data in CrUX told a different story:
Interaction to Next Paint (75th percentile)
Before refactor: 220 ms
After refactor: 410 ms
Long Animation Frames > 50ms
Before refactor: 1.2 per page
After refactor: 3.8 per page
The team had followed every React 19 talking point. startTransition was wrapped around the state setter. The non-urgent updates were correctly marked. The first paint felt snappier in the office. Real users on mid-tier Android phones were seeing it slower.
The PerformanceObserver output from a real device session made the cause obvious once I looked:
{
name: 'click',
duration: 412,
processingStart: 8,
processingEnd: 396, // 388ms of script work
startTime: 14821
}
The click handler itself was running for nearly 400 ms even though the state update was inside startTransition. The transition was not buying back any responsiveness because the bottleneck was the synchronous work that happened before the state setter was called.
Why It Happens
useTransition in React 19.2 marks a state update as non-urgent so React can yield to higher-priority work like input and paint. What it does not do is yield within the handler before it reaches the state setter. If your handler does heavy work (filtering a large array, computing aggregations, formatting prices, building a sort comparator), that work still runs synchronously inside the click handler and blocks the next paint just as it did before.
The mental model trap is treating startTransition as "this entire callback is now low priority". It is not. Only the update is. The handler runs at the priority of the event that fired it (a click is discrete priority, the highest). React waits until the handler returns before scheduling the transition, and INP is measured from the input event to the next paint after the handler completes. So a 400 ms handler produces a 400 ms INP regardless of whether the state update inside it was urgent or not.
Two things made the regression worse in this codebase. First, wrapping the setter in a transition convinced the team to stop worrying about handler weight, so they kept piling synchronous work into the click path. Second, React 19.2's automatic batching means the transition flush can land in the same frame as the next user input, producing a Long Animation Frame that counts against INP a second time.
The INP guidance from web.dev covers this: the metric measures the full interaction-to-paint, not just the React render. A transition only helps if the work it defers is the part causing the delay.
The Fix
Three patterns, applied together.
Pattern 1: Yield before heavy work in the handler. Push computation off the click frame using scheduler.yield() (or a setTimeout(0) fallback) so the browser can paint a visual acknowledgement first:
import { useTransition } from 'react'
export function FilterPanel({ products }: Props) {
const [isPending, startTransition] = useTransition()
const [filtered, setFiltered] = useState(products)
async function handleFilter(filterFn: (p: Product) => boolean) {
// Yield before doing heavy work. Browser paints the pressed state.
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield()
} else {
await new Promise((r) => setTimeout(r, 0))
}
const result = products.filter(filterFn)
startTransition(() => {
setFiltered(result)
})
}
return <button onClick={() => handleFilter((p) => p.inStock)}>In stock</button>
}
The handler returns within a few milliseconds. The expensive filter happens on the next task tick. INP is measured against that first paint, not the eventual render. On the client site this dropped p75 INP from 410 ms to 180 ms overnight.
Pattern 2: Move heavy computation out of the handler entirely. If the work is genuinely expensive (sorting 5,000 rows, computing aggregations), it does not belong on the main thread at all. Push it to a Web Worker:
// filter.worker.ts
self.onmessage = (e: MessageEvent<{ products: Product[]; filter: string }>) => {
const result = e.data.products.filter(/* ... */)
self.postMessage(result)
}
The click handler posts to the worker and returns. The worker computes off-thread. When it posts back, you update state inside startTransition. INP becomes the cost of posting a message, which is sub-millisecond.
Pattern 3: Memoise derived data once, not per click. A common cause of slow click handlers is re-deriving the same shape on every interaction. Lift it to a useMemo that depends only on the source data:
const indexedProducts = useMemo(
() => ({
byCategory: Object.groupBy(products, (p) => p.category),
inStock: products.filter((p) => p.inStock),
onSale: products.filter((p) => p.salePrice != null),
}),
[products],
)
function handleFilter(key: keyof typeof indexedProducts) {
startTransition(() => {
setFiltered(indexedProducts[key])
})
}
The click handler is now a single object lookup. The expensive grouping happens once when products change, not every time the user clicks a chip. If your codebase has reactCompiler: true you can drop the useMemo: the compiler memoises the same expression for you.
Verify the fix in field data, not lab data. Lighthouse runs on a beefy synthetic device and almost never reproduces an INP regression on real phones. Confirm by reading INP from a real PerformanceObserver:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.log('Slow interaction:', entry.name, entry.duration, 'ms')
}
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true })
Ship this behind a sample(1%) gate, log to your analytics, and watch the p75 over a real day of traffic. Lab numbers can lie. Field numbers cannot.
The Lesson
useTransition defers the update, not the handler. If the work blocking INP runs before the state setter, the transition buys you nothing. Yield before heavy computation, push genuinely expensive work to a worker, and memoise derived shapes once instead of per click. Confirm the win in field data with a real PerformanceObserver, not in Lighthouse.
If your Core Web Vitals are tanking despite a React 19 refactor that was supposed to fix them, that is exactly the kind of debugging I get paid to land. See my services. For another INP debugging walkthrough, read Long Animation Frames API for INP debugging.
INP getting worse after a refactor that was supposed to fix it? Get it shipped.