Detailed guide

Inp Optimization Guide

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

  1. Open DevTools → Performance tab
  2. Enable “Web Vitals” checkbox
  3. Record while interacting with the page
  4. 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

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

Related articles

Related version

Introduction

Inp Explained

Interaction to Next Paint (INP) is one of Google's Core Web Vitals—a set of metrics that measure real-world user experience on your website

Category hub

Hub

Performance Seo Hub

Performance is a critical ranking factor and directly impacts user experience

In the same category

Detailed guide

Cls Optimization Guide

Cumulative Layout Shift (CLS) measures the visual stability of your page

Detailed guide

Lcp Optimization Guide

Largest Contentful Paint (LCP) measures when the largest content element becomes visible to users