View contents
Complete INP Optimization Guide: Achieving Fast Page Responsiveness
Introduction
Interaction to Next Paint (INP) measures how quickly your page responds to user interactions. Achieving a good INP score (under 200ms) requires understanding the three phases of interaction latency and applying targeted optimization techniques.
This guide covers advanced strategies based on Google’s official recommendations and modern browser APIs to help you achieve fast, consistent responsiveness.
Understanding INP Phases
Every interaction goes through three phases:
Interaction Breakdown (200ms budget):
┌─────────────────────────────────────────────────────────┐
│ 1. INPUT DELAY │ Time until handlers start │
│ Target: <50ms │ Caused by: Main thread blocking │
├─────────────────────────────────────────────────────────┤
│ 2. PROCESSING TIME │ Time for handlers to execute │
│ Target: <100ms │ Caused by: Heavy event handlers │
├─────────────────────────────────────────────────────────┤
│ 3. PRESENTATION │ Time to render the update │
│ DELAY: <50ms │ Caused by: Complex rendering │
└─────────────────────────────────────────────────────────┘
Debugging INP Issues
Using Chrome DevTools Performance Panel
- Open DevTools → Performance tab
- Enable “Web Vitals” checkbox
- Record while interacting with the page
- Look for:
- Long Tasks (> 50ms) highlighted in red
- Interaction markers showing INP duration
- Event handlers taking excessive time
DevTools Analysis:
┌─────────────────────────────────────────────────┐
│ Interaction: click on button │
├─────────────────────────────────────────────────┤
│ Input Delay: 45ms (✅ Good) │
│ Processing: 180ms (❌ Too long) │
│ Presentation: 25ms (✅ Good) │
│ Total INP: 250ms (⚠️ Needs Improvement) │
├─────────────────────────────────────────────────┤
│ Issue: Event handler "handleSubmit" took 180ms │
│ Solution: Break up or optimize handler │
└─────────────────────────────────────────────────┘
Using Long Animation Frames API
Monitor long animation frames programmatically:
// Detect frames that hurt INP
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Long Animation Frames > 50ms
console.log('Long Frame:', {
duration: entry.duration,
blockingDuration: entry.blockingDuration,
scripts: entry.scripts.map(s => ({
invoker: s.invoker,
duration: s.duration
}))
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Using Web Vitals Library Attribution
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
console.log('INP:', metric.value);
console.log('Attribution:', {
eventTarget: metric.attribution.eventTarget,
eventType: metric.attribution.eventType,
loadState: metric.attribution.loadState,
eventTime: metric.attribution.eventTime,
presentationDelay: metric.attribution.presentationDelay,
processingDuration: metric.attribution.processingDuration,
inputDelay: metric.attribution.inputDelay
});
});
Reducing Input Delay
Input delay occurs when the main thread is busy when the user interacts.
1. Break Up Long Tasks
According to web.dev documentation, tasks over 50ms are “long tasks” that block the main thread:
// ❌ Long task blocks main thread
function processAllData(data) {
data.forEach(item => {
heavyProcessing(item); // Runs for 500ms total
});
}
// ✅ Break into chunks that yield to main thread
async function processAllDataYielding(data) {
const chunkSize = 50;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
chunk.forEach(item => heavyProcessing(item));
// Yield to main thread between chunks
await yieldToMain();
}
}
// Modern yield function using scheduler.yield
async function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
// Fallback for older browsers
return new Promise(resolve => setTimeout(resolve, 0));
}
2. Use the Scheduler API
The Scheduler API provides fine-grained control over task priority:
// Schedule tasks by priority
async function handleUserAction() {
// High priority: Immediate visual feedback
await scheduler.postTask(() => {
button.classList.add('loading');
}, { priority: 'user-blocking' });
// Medium priority: Core functionality
await scheduler.postTask(() => {
processFormData();
}, { priority: 'user-visible' });
// Low priority: Analytics, logging
scheduler.postTask(() => {
trackEvent('form_submitted');
}, { priority: 'background' });
}
3. Defer Non-Critical Scripts
Move scripts that don’t need to run during page load:
<!-- ❌ Blocking scripts during interaction -->
<script src="heavy-analytics.js"></script>
<!-- ✅ Defer non-critical scripts -->
<script src="heavy-analytics.js" defer></script>
<!-- ✅ Load after interaction -->
<script>
document.addEventListener('click', () => {
import('./non-critical-module.js');
}, { once: true });
</script>
Optimizing Processing Time
Processing time is the duration of your event handlers.
1. Keep Event Handlers Lightweight
// ❌ Heavy synchronous work in handler
button.addEventListener('click', () => {
const data = fetchDataSync(); // 100ms
processData(data); // 100ms
updateUI(data); // 50ms
// Total: 250ms blocking
});
// ✅ Immediate feedback, deferred processing
button.addEventListener('click', async () => {
// Immediate visual feedback (< 10ms)
button.disabled = true;
button.textContent = 'Loading...';
// Yield to allow paint
await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
// Then do heavy work
const data = await fetchData();
processData(data);
updateUI(data);
});
2. Use Web Workers for Heavy Computation
Move CPU-intensive work off the main thread:
// main.js
const worker = new Worker('computation-worker.js');
button.addEventListener('click', () => {
// Immediate feedback
showLoadingState();
// Send work to worker
worker.postMessage({ type: 'PROCESS', data: largeDataset });
});
worker.onmessage = (e) => {
if (e.data.type === 'RESULT') {
hideLoadingState();
displayResults(e.data.result);
}
};
// computation-worker.js
self.onmessage = (e) => {
if (e.data.type === 'PROCESS') {
// Heavy computation runs off main thread
const result = heavyCalculation(e.data.data);
self.postMessage({ type: 'RESULT', result });
}
};
3. Virtualize Long Lists
Don’t render thousands of DOM nodes:
// ❌ Rendering 10,000 items
function renderList(items) {
items.forEach(item => {
container.appendChild(createItemElement(item));
});
}
// ✅ Virtual scrolling - only render visible items
class VirtualList {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2;
this.render();
container.addEventListener('scroll', () => this.render());
}
render() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(startIndex + this.visibleCount, this.items.length);
// Only render visible items
this.container.innerHTML = '';
for (let i = startIndex; i < endIndex; i++) {
const el = createItemElement(this.items[i]);
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
this.container.appendChild(el);
}
}
}
4. Debounce and Throttle Input Handlers
// ❌ Processing every keystroke
input.addEventListener('input', (e) => {
expensiveSearch(e.target.value);
});
// ✅ Debounced - wait for user to stop typing
const debouncedSearch = debounce((query) => {
expensiveSearch(query);
}, 300);
input.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
// Debounce utility
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
Reducing Presentation Delay
Presentation delay is the time from when handlers complete until the browser paints.
1. Minimize Layout Thrashing
// ❌ Layout thrashing - read/write interleaved
elements.forEach(el => {
const width = el.offsetWidth; // Read (forces layout)
el.style.width = (width + 10) + 'px'; // Write (invalidates layout)
});
// ✅ Batch reads, then batch writes
const widths = elements.map(el => el.offsetWidth); // All reads first
elements.forEach((el, i) => {
el.style.width = (widths[i] + 10) + 'px'; // All writes after
});
2. Reduce DOM Size
Large DOMs slow down rendering:
DOM Size Impact on INP:
├── < 1,500 nodes: Optimal
├── 1,500 - 5,000: Acceptable
├── 5,000 - 10,000: Performance impact
└── > 10,000: Significant delays
Recommendations:
- Remove hidden elements from DOM
- Use virtual scrolling for long lists
- Lazy load off-screen content
- Use content-visibility: auto
3. Use CSS Containment
Limit the scope of rendering updates:
/* Isolate component rendering */
.interactive-component {
contain: layout style paint;
}
/* For complex components */
.complex-widget {
contain: strict; /* Most restrictive */
content-visibility: auto;
contain-intrinsic-size: 200px 300px;
}
4. Avoid Forced Synchronous Layouts
// ❌ Forcing layout calculation
button.addEventListener('click', () => {
element.style.width = '100px';
console.log(element.offsetWidth); // Forces synchronous layout
element.style.height = '50px';
});
// ✅ Batch style changes, read later
button.addEventListener('click', () => {
element.style.width = '100px';
element.style.height = '50px';
// Read in next frame if needed
requestAnimationFrame(() => {
console.log(element.offsetWidth);
});
});
Third-Party Script Management
Third-party scripts often hurt INP by blocking the main thread.
1. Load Third-Party Scripts Asynchronously
<!-- ❌ Blocking third-party script -->
<script src="https://cdn.example.com/widget.js"></script>
<!-- ✅ Async loading -->
<script src="https://cdn.example.com/widget.js" async></script>
<!-- ✅ Deferred loading -->
<script src="https://cdn.example.com/analytics.js" defer></script>
2. Use Facades for Heavy Embeds
<!-- Instead of loading YouTube iframe immediately -->
<div class="youtube-facade"
data-video-id="dQw4w9WgXcQ"
onclick="loadYouTube(this)">
<img src="/youtube-thumbnail.jpg" alt="Video">
<button class="play-button">▶️</button>
</div>
<script>
function loadYouTube(facade) {
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube.com/embed/${facade.dataset.videoId}?autoplay=1`;
iframe.allow = 'autoplay; encrypted-media';
facade.replaceWith(iframe);
}
</script>
3. Use Partytown for Third-Party Scripts
<!-- Run third-party scripts in Web Worker -->
<script src="partytown.js"></script>
<script type="text/partytown">
// This analytics code runs off main thread
analytics.track('page_view');
</script>
Monitoring INP in Production
Real User Monitoring Setup
import { onINP } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
page: window.location.pathname,
// INP-specific attribution
eventType: metric.attribution?.eventType,
eventTarget: metric.attribution?.eventTarget,
inputDelay: metric.attribution?.inputDelay,
processingDuration: metric.attribution?.processingDuration
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
}
}
onINP(sendToAnalytics);
Setting Up Alerts
onINP((metric) => {
// Alert on poor INP
if (metric.value > 500) {
console.error('Poor INP detected:', metric);
// Send to error tracking
errorTracking.captureMessage('Poor INP', {
value: metric.value,
threshold: 500,
page: window.location.href
});
}
});
INP Optimization Checklist
Input Delay Reduction
├── [ ] Long tasks broken into < 50ms chunks
├── [ ] Using scheduler.yield() for yielding
├── [ ] Non-critical scripts deferred
├── [ ] Third-party scripts loaded async
└── [ ] Main thread not blocked during load
Processing Time Optimization
├── [ ] Event handlers are lightweight
├── [ ] Heavy computation in Web Workers
├── [ ] Long lists virtualized
├── [ ] Input handlers debounced/throttled
└── [ ] Immediate visual feedback provided
Presentation Delay Reduction
├── [ ] No layout thrashing
├── [ ] DOM size under 5,000 nodes
├── [ ] CSS containment used
├── [ ] No forced synchronous layouts
└── [ ] Efficient CSS selectors
Monitoring
├── [ ] Web Vitals library tracking INP
├── [ ] Attribution data collected
├── [ ] Alerts for poor INP
├── [ ] Regular DevTools profiling
Related Articles
- INP Explained - INP fundamentals
- LCP Optimization Guide - Optimize largest paint
- JavaScript Performance Guide - JS optimization
References
This article was enhanced using authoritative sources:
[1] web.dev: Optimize Interaction to Next Paint https://web.dev/articles/optimize-inp Google’s comprehensive INP optimization guide covering the three phases of interaction latency, specific techniques for each phase, and modern browser APIs for improving responsiveness.
[2] web.dev: Long Tasks and Long Animation Frames https://web.dev/articles/long-tasks-devtools Documentation on identifying long tasks (>50ms) using Chrome DevTools and the Long Animation Frames API. Essential for finding what’s blocking the main thread and hurting INP.
[3] MDN Web Docs: Web Workers API https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API Technical reference for the Web Workers API, enabling heavy computation to run off the main thread. Critical for keeping the main thread responsive to user interactions.
[4] Chrome Developers: Scheduler API https://developer.chrome.com/docs/web-platform/scheduler Documentation on scheduler.yield(), scheduler.postTask(), and priority hints. Modern APIs for explicitly yielding to the main thread and scheduling work by priority.
Note: This article is part of our SEO analysis series. Explore all articles in the Performance SEO Hub.
Sources: web.dev (INP Optimization), MDN Web Docs, Chrome Developers