what type of event caused it. Without this, you're guessing.
2. Decouple Urgent vs. Non-Urgent Updates
React's useTransition hook allows you to mark state updates as non-urgent. When wrapped in a transition, React defers the update until the browser has painted higher-priority content.
import { useState, useTransition, useMemo } from 'react';
interface CatalogItem {
id: string;
category: string;
metadata: Record<string, unknown>;
}
export function CatalogBrowser({ inventory }: { inventory: CatalogItem[] }) {
const [activeFilter, setActiveFilter] = useState('');
const [pendingFilter, setPendingFilter] = useState('');
const [isComputing, startTransition] = useTransition();
const handleFilterInput = (rawValue: string) => {
setActiveFilter(rawValue); // Immediate: updates input field
startTransition(() => {
setPendingFilter(rawValue); // Deferred: triggers heavy filtering
});
};
const visibleItems = useMemo(() => {
if (!pendingFilter) return inventory;
const normalized = pendingFilter.toLowerCase();
return inventory.filter((item) =>
item.category.toLowerCase().includes(normalized)
);
}, [inventory, pendingFilter]);
return (
<section>
<input
type="text"
value={activeFilter}
onChange={(e) => handleFilterInput(e.target.value)}
placeholder="Filter by category..."
aria-busy={isComputing}
/>
{isComputing && <span className="sr-only">Updating results...</span>}
<ResultGrid data={visibleItems} />
</section>
);
}
Architecture Rationale: We split the input state (activeFilter) from the computation state (pendingFilter). The input updates synchronously, guaranteeing instant visual feedback. The filtering logic runs inside startTransition, allowing React to interrupt and resume the work as the browser paints. This prevents the main thread from locking during reconciliation.
3. Defer Expensive Child Components
When the heavy work lives in a descendant component, useDeferredValue provides a cleaner abstraction. It creates a deferred version of a value that updates only when the browser is idle.
import { useState, useDeferredValue } from 'react';
export function QueryDashboard() {
const [searchInput, setSearchInput] = useState('');
const deferredSearch = useDeferredValue(searchInput);
return (
<div className="dashboard-layout">
<SearchBar onQueryChange={setSearchInput} />
{/* Renders with previous value until React can safely update */}
<HeavyAnalyticsPanel searchTerms={deferredSearch} />
</div>
);
}
Why this works: useDeferredValue doesn't block the parent render. It tells React: "Keep showing the old value here until you have spare cycles to compute the new one." This is ideal for charts, data grids, or complex visualizations that don't need to update on every keystroke.
4. Chunk Long-Running Tasks
Sometimes business logic requires processing large datasets synchronously. Instead of blocking the main thread, yield control back to the browser periodically using the Scheduler API.
async function batchTransformRecords(rawData: unknown[]) {
const processed: unknown[] = [];
for (let i = 0; i < rawData.length; i++) {
processed.push(computeTransformation(rawData[i]));
// Yield every 40 iterations to allow browser painting
if (i % 40 === 0) {
await scheduler.yield();
}
}
return processed;
}
Architecture Decision: scheduler.yield() is superior to setTimeout or requestAnimationFrame for this use case because it integrates with the browser's task scheduler. It pauses the current task, allows higher-priority work (like user input or painting) to execute, then resumes exactly where it left off. This prevents the "unresponsive script" warnings and keeps INP healthy.
5. Virtualize Large Collections
Rendering hundreds of DOM nodes guarantees reconciliation overhead. Virtualization mounts only the visible items plus a small buffer.
import { useRef, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
interface RecordRow {
uid: string;
label: string;
status: 'active' | 'archived';
}
export function VirtualizedRecordList({ records }: { records: RecordRow[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: records.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 64,
overscan: 3,
});
return (
<div ref={containerRef} className="scroll-container" style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: `${virtualRow.start}px`,
height: '64px',
width: '100%',
}}
>
<RecordCard data={records[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Production Insight: Virtualization reduces DOM node count by 90%+ for lists exceeding 100 items. This directly slashes reconciliation time and memory allocation. Always use fixed or accurately estimated heights; dynamic heights require additional measurement logic and can cause layout thrashing if misconfigured.
6. Defer Heavy Component Loading
Loading complex UI overlays on interaction should never block the main thread. Preload on hover, mount on click.
import dynamic from 'next/dynamic';
import { useState, useCallback } from 'react';
const ReportingOverlay = dynamic(() => import('@/ui/ReportingOverlay'), {
loading: () => <div className="skeleton-loader" />,
});
export function ActionToolbar() {
const [showReport, setShowReport] = useState(false);
const preloadReport = useCallback(() => {
import('@/ui/ReportingOverlay');
}, []);
return (
<div className="toolbar">
<button
onClick={() => setShowReport(true)}
onMouseEnter={preloadReport}
onFocus={preloadReport}
>
Generate Report
</button>
{showReport && <ReportingOverlay onClose={() => setShowReport(false)} />}
</div>
);
}
Why this pattern: The click handler only toggles a boolean. The heavy component bundle is fetched during the hover/focus window, which typically lasts 200–400ms. By the time the user clicks, the module is already in memory. This eliminates the initial load latency that would otherwise spike INP.
7. Isolate Third-Party Execution
Third-party analytics, chat widgets, and tracking scripts often execute synchronously on user interactions. Next.js provides a dedicated component to control script loading strategies.
import Script from 'next/script';
export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://cdn.tracking-provider.com/analytics.js"
strategy="lazyOnload"
onLoad={() => console.log('Tracking script initialized')}
/>
</body>
</html>
);
}
Architecture Note: lazyOnload defers script execution until after the page becomes interactive and the main thread is idle. This prevents third-party code from competing with React's reconciliation cycle during critical user interactions.
Pitfall Guide
1. Over-Deferring Critical Feedback
Explanation: Wrapping every state update in useTransition makes the UI feel broken. Users expect immediate visual confirmation for actions like button presses or form validation.
Fix: Only defer updates that trigger expensive computations or large subtree reconciliations. Keep direct feedback (loading spinners, button states, input echoes) synchronous.
2. Ignoring metric.attribution
Explanation: Teams often apply optimizations blindly without identifying which specific interaction is failing INP. This leads to wasted engineering time on non-critical paths.
Fix: Always log metric.attribution.interactionTarget and metric.attribution.interactionType. Use these selectors to pinpoint exact components and event handlers in production.
3. Memoizing Everything Indiscriminately
Explanation: Applying React.memo, useMemo, and useCallback everywhere increases memory overhead and can actually degrade performance due to comparison costs. It doesn't solve INP if the underlying render tree is still large.
Fix: Profile first. Only memoize components that render frequently with stable props and contain expensive calculations. INP is solved by scheduling and DOM reduction, not blanket memoization.
4. Using useEffect for User Feedback
Explanation: useEffect runs after the browser paints. If you use it to update UI based on user input, the feedback will always lag by at least one frame.
Fix: Use synchronous state updates for immediate feedback. Reserve useEffect for side effects like data fetching, subscriptions, or manual DOM manipulations that don't block rendering.
5. Virtualizing Without Height Consistency
Explanation: Virtualization libraries rely on predictable item sizes to calculate scroll position and visible range. Dynamic or variable heights cause miscalculations, leading to blank spaces or layout jumps.
Fix: Use fixed heights whenever possible. If variable heights are unavoidable, implement a measurement cache or use libraries that support dynamic sizing with explicit measure callbacks.
6. Blocking Main Thread with Analytics on Interaction
Explanation: Sending telemetry synchronously inside event handlers adds network serialization and JSON parsing to the critical path. This directly inflates INP.
Fix: Use navigator.sendBeacon() or queue analytics data in a microtask. Defer transmission until after the next paint cycle completes.
Explanation: Chrome DevTools CPU throttling (e.g., 4x slowdown) is useful for debugging but doesn't replicate real device behavior. Production devices have different memory constraints, thermal throttling, and browser engines.
Fix: Validate INP improvements using Real User Monitoring (RUM) data. Test on actual mid-tier devices or use cloud-based device farms for accurate performance baselines.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Input filtering triggers 500+ component re-renders | useTransition + useDeferredValue | Decouples input responsiveness from heavy reconciliation | Low (React built-in) |
| Long-running data transformation blocks UI | scheduler.yield() chunking | Yields to browser without losing computation state | Low (native API) |
| Scrollable list with 200+ DOM nodes | @tanstack/react-virtual | Reduces mounted nodes by 90%+, slashes reconciliation cost | Medium (bundle size + implementation) |
| Heavy modal/dashboard loads on click | next/dynamic + hover preload | Moves bundle fetch to idle time, click becomes instant | Low (code splitting overhead) |
| Third-party analytics spike INP on interaction | next/script lazyOnload | Defers execution until main thread is idle | Zero (configuration change) |
Configuration Template
// lib/performance/inp-tracker.ts
import { onINP } from 'web-vitals';
type INPHandler = (metric: {
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
target: string | null;
eventType: string | null;
}) => void;
export function setupINPMonitoring(handler: INPHandler) {
onINP((metric) => {
const target = metric.attribution?.interactionTarget;
const selector = target ?
target.tagName.toLowerCase() +
(target.id ? `#${target.id}` : '') +
(target.className ? `.${target.className.split(' ')[0]}` : '')
: null;
handler({
value: metric.value,
rating: metric.rating,
target: selector,
eventType: metric.attribution?.interactionType ?? null,
});
});
}
// Usage in app root
import { setupINPMonitoring } from '@/lib/performance/inp-tracker';
setupINPMonitoring((data) => {
if (data.rating !== 'good') {
console.warn(`[INP Alert] ${data.value}ms on ${data.target} (${data.eventType})`);
// Send to monitoring service
}
});
Quick Start Guide
- Install the measurement library: Run
npm install web-vitals and initialize onINP in your application entry point.
- Identify the worst interaction: Check your analytics dashboard for the highest INP value and note the
interactionTarget selector.
- Apply the appropriate primitive: If it's an input triggering heavy work, wrap the state update in
useTransition. If it's a child component, pass the prop through useDeferredValue.
- Validate in production: Deploy the change and monitor RUM data for 24–48 hours. Confirm the 75th percentile INP drops below 200ms.
- Iterate: Repeat the measurement → optimization → validation cycle for the next highest INP contributor until all critical paths meet the threshold.