Optimizing FID and INP (interface responsiveness)
FID (First Input Delay) replaced by INP (Interaction to Next Paint) in March 2024. INP is stricter: measures all interactions during session, not just first. INP goal: ≤ 200 ms.
How INP works
INP = time from user action (mousedown, keydown, pointerdown) to next frame render by browser.
Delay consists of:
- Input delay — waiting for main thread to free from current task
- Processing time — execution time of event handlers
- Presentation delay — time until actual render (layout, paint, composite)
Diagnosing slow interactions
// Monitor all interactions
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn(`Slow interaction: ${entry.name}`, {
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
inputDelay: entry.processingStart - entry.startTime,
processingTime: entry.processingEnd - entry.processingStart,
presentationDelay: entry.startTime + entry.duration - entry.processingEnd,
});
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
Chrome DevTools → Performance → record page → filter Long Tasks (red bar). Any task > 50 ms is optimization candidate.
Eliminating Long Tasks
Breaking synchronous computations:
// Before: blocks main thread for hundreds of ms
function filterProducts(products, filters) {
return products.filter(p => matchesFilters(p, filters));
}
// After: yield every 50 items
async function filterProductsAsync(products, filters) {
const results = [];
for (let i = 0; i < products.length; i++) {
if (matchesFilters(products[i], filters)) {
results.push(products[i]);
}
if (i % 50 === 0 && i > 0) {
await scheduler.yield(); // Chrome 115+
// fallback: await new Promise(r => setTimeout(r, 0));
}
}
return results;
}
Web Worker for CPU-intensive tasks:
// worker.js
self.onmessage = function({ data: { products, filters } }) {
const results = products.filter(p => matchesFilters(p, filters));
self.postMessage(results);
};
// main.js
const worker = new Worker('/js/filter-worker.js');
worker.postMessage({ products, filters });
worker.onmessage = ({ data }) => setFilteredProducts(data);
React component optimization
Problem: excessive re-render on every keystroke:
// Bad: synchronous filtering on every keystroke
function ProductList() {
const [query, setQuery] = useState('');
const filtered = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return <>
<input onChange={e => setQuery(e.target.value)} />
<ul>{filtered.map(p => <ProductItem key={p.id} product={p} />)}</ul>
</>;
}
// Good: input field urgent, list deferred
function ProductList() {
const [query, setQuery] = useState('');
const [deferredQuery, setDeferredQuery] = useState('');
const filtered = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[deferredQuery]
);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // urgent — field responds instantly
startTransition(() => {
setDeferredQuery(value); // non-critical — list updates later
});
}
return <>
<input value={query} onChange={handleChange} />
<ul>{filtered.map(p => <ProductItem key={p.id} product={p} />)}</ul>
</>;
}
**Virtual
izing long lists:**
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<div key={virtualRow.index}
style={{ transform: `translateY(${virtualRow.start}px)`, position: 'absolute', width: '100%' }}>
<ProductItem product={products[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Event handler optimization
// Throttle for scroll/resize handlers
const handleScroll = throttle(() => {
updateStickyHeader();
}, 16); // ~60fps
window.addEventListener('scroll', handleScroll, { passive: true });
// passive: true — tells browser handler won't call preventDefault
// Allows browser to scroll without waiting for JS
Third-party scripts
Chats, pixels, analytics — common cause of poor INP. They run in main thread and block interactions.
<!-- Load after main content -->
<script>
window.addEventListener('load', () => {
setTimeout(() => {
// Initialize chat/pixel
loadChatWidget();
}, 3000); // 3 second delay after load
});
</script>
Alternative — Partytown (Astro/Next.js): runs third-party scripts in Web Worker, completely freeing main thread.
INP targets
| Interaction type | Goal |
|---|---|
| Button click | < 100 ms |
| Search input | < 150 ms |
| Modal open | < 200 ms |
| Catalog filter | < 200 ms |
Optimization time: 3–7 days depending on number of problematic interactions.







