Core Web Vitals: INP Optimisation for WordPress Themes and Plugins

Interaction to Next Paint (INP) replaced First Input Delay (FID) as a Core Web Vitals metric in March 2024. Unlike FID, which measured only the delay before the first interaction was processed, INP measures the worst interaction latency across the entire page lifetime — any slow click, tap, or key press can fail INP. WordPress themes with heavy admin bars, WooCommerce add-to-cart handlers, and plugins that add synchronous event listeners are common INP culprits.

Problem: A WordPress site scores poorly on INP (Interaction to Next Paint) — interactions with menus, forms, or dynamic content feel sluggish — but the source of the long tasks in the main thread is not obvious from standard performance metrics.

Solution: Profile INP in Chrome DevTools Performance panel: click the interaction, find the long task in the Main thread, and identify the callback that took the most time. Common INP causes in WordPress themes are synchronous JavaScript that runs on every interaction (jQuery handlers loading large DOM subtrees), third-party scripts blocking the main thread, and forced layout reflows from style reads followed by writes. Break long tasks with scheduler.yield() and defer non-critical work with requestIdleCallback().


The code and scripts below measure INP with the web-vitals library, identify long tasks with the PerformanceObserver Long Animation Frames API, defer expensive event handler work with scheduler.yield(), and show WordPress-specific patterns that commonly cause INP failures.


// ── 1. Measure INP in the field using web-vitals ──────────────────────────
import { onINP } from 'web-vitals';

onINP( ( { value, rating, entries } ) => {
    console.log( `INP: ${value}ms — ${rating}` );  // good < 200ms, poor > 500ms
    // Send to analytics
    if ( typeof gtag !== 'undefined' ) {
        gtag( 'event', 'INP', { value, event_category: 'Web Vitals', non_interaction: true } );
    }
}, { reportAllChanges: true } );

// ── 2. Detect long animation frames (LoAF — Chrome 123+) ─────────────────
// LoAF is more useful than Long Tasks for INP: it shows what ran during a frame
new PerformanceObserver( ( list ) => {
    for ( const entry of list.getEntries() ) {
        if ( entry.duration > 50 ) {
            console.warn( `Long frame: ${entry.duration.toFixed(1)}ms` );
            for ( const script of entry.scripts ) {
                console.warn( `  Source: ${script.sourceURL}:${script.sourceCharPosition}` );
                console.warn( `  Invoker: ${script.invoker}` );  // e.g., "BUTTON#add-to-cart.onclick"
            }
        }
    }
} ).observe( { type: 'long-animation-frame', buffered: true } );

// ── 3. Defer expensive handler work with scheduler.yield() ────────────────
// Before: synchronous handler blocks the main thread
document.querySelector( '#add-to-cart' ).addEventListener( 'click', async ( e ) => {
    updateCartUI();           // immediate visual feedback

    await scheduler.yield(); // yield to browser → INP measured here (short!)

    await processCartLogic( e.target.dataset.productId );   // expensive work deferred
    await updateMiniCart();
} );

// ── 4. WordPress: avoid synchronous AJAX in click handlers ────────────────
// BAD: synchronous XHR blocks the main thread for the entire request duration
document.querySelector( '.wishlist-btn' ).addEventListener( 'click', function () {
    const xhr = new XMLHttpRequest();
    xhr.open( 'POST', ajaxurl, false );  // false = SYNCHRONOUS — blocks everything
    xhr.send( `action=add_to_wishlist&id=${this.dataset.id}` );
} );

// GOOD: async fetch + yield for visual feedback first
document.querySelector( '.wishlist-btn' ).addEventListener( 'click', async function () {
    this.classList.add( 'is-loading' );   // immediate visual feedback
    await scheduler.yield();              // yield — browser paints the loading state

    const data = new FormData();
    data.append( 'action', 'add_to_wishlist' );
    data.append( 'id', this.dataset.id );
    data.append( 'nonce', wpAjax.nonce );

    const resp = await fetch( wpAjax.ajaxurl, { method: 'POST', body: data } );
    const json = await resp.json();

    this.classList.remove( 'is-loading' );
    this.classList.toggle( 'is-active', json.success );
} );


NOTE: scheduler.yield() is available in Chrome 115+ and Safari 18+; for broader browser support, use await new Promise( r => setTimeout( r, 0 ) ) as a fallback — it provides the same main-thread yielding behaviour using setTimeout with a zero delay, which schedules a macrotask and allows the browser to process any pending paint and input events before continuing.