React/Next.js Web Vitals: Sub-Second LCP and INP (Interaction to Next Paint) Optimization

With Google replacing FID with INP (Interaction to Next Paint) as a core Web Vital, performance measurement has evolved beyond initial page load speed. Modern web applications are judged on UI responsiveness—how quickly the page updates after a user clicks, taps, or types.
In React and Next.js applications, heavy JavaScript execution can block the main thread, resulting in poor INP scores (> 200ms).
This article covers actionable techniques for optimizing INP and driving LCP under 1 second in React 19 and Next.js 15+ using useTransition, scheduler.yield(), and priority asset streaming.
Key Takeaways
- INP Threshold: Measures latency from user interaction until the next frame paints. Aim for under 100ms.
- Breaking Long Tasks: Heavy JS tasks > 50ms block the main thread. Use
scheduler.yield()to yield control back to the browser renderer.- React 19 Concurrent API:
useTransitiondeprioritizes heavy state updates to ensure user input handlers execute immediately without UI lag.- Sub-Second LCP: Combine
fetchpriority="high", AVIF image formats, and Edge CDN HTML streaming.
1. Understanding INP Latency Breakdown
Total INP = [Input Delay] + [Processing Time] + [Presentation Delay]
2. INP Optimization: scheduler.yield() & useTransition
2.1 Chunking Long Tasks with scheduler.yield()
export async function yieldToMain() {
if ('scheduler' in window && 'yield' in (window as any).scheduler) {
await (window as any).scheduler.yield();
} else {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
export async function processLargeDataset(items: Array<any>) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
await yieldToMain();
renderChunk(items.slice(i, i + CHUNK_SIZE));
}
}
2.2 React 19 useTransition for Non-Blocking Updates
import React, { useState, useTransition } from 'react';
export function SearchFilter() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // Immediate input response
startTransition(() => {
setResults(heavyFilterAlgorithm(value)); // Deferred non-blocking filtering
});
};
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
{isPending && <span>Updating...</span>}
</div>
);
}
3. Performance Benchmark Comparison
| Metric | Before Optimization | After Optimization | Good Threshold |
|---|---|---|---|
| INP | 340 ms | 48 ms | $\le 200\text{ ms}$ |
| LCP | 2.8 s | 0.82 s | $\le 2.5\text{ s}$ |
| Long Tasks (>50ms) | 14 | 0 | 0 recommended |
| Total Blocking Time (TBT) | 620 ms | 15 ms | $\le 200\text{ ms}$ |