Your WooCommerce product page scores 98 on PageSpeed Insights. Your LCP is 1.1s. Your CLS is 0.02. Then you open Search Console and the Core Web Vitals report shows 67% of real users are getting a “Poor” INP. Google is quietly deprioritising your pages in search, and the lab tools you’ve been trusting for two years didn’t warn you once.
This is the WordPress INP problem in 2026. Field data tells a story that synthetic tests can’t see, and most WordPress sites are failing it without realising. If you want to pass Google’s INP metric on WordPress, you have to stop treating it like the old FID and start debugging it like what it actually is: a measurement of every click, tap, and keypress a visitor ever makes, taking the worst one, and judging your whole site on it.
This guide walks through how INP actually breaks on WordPress sites, how to debug it with Chrome DevTools in under ten minutes, and the specific fixes that work for 2026 — with working PHP and JavaScript you can paste into your theme today.
Why INP Punishes WordPress Harder Than Any Other Metric
INP replaced First Input Delay as a Core Web Vital in March 2024, and it’s a different beast. FID only looked at the first interaction on a page. You could have one fast click on arrival and fail every interaction after and still pass. INP takes the 98th percentile of every interaction during the session. Miss once, and the metric remembers.
WordPress sites get hit harder than static sites or headless apps for three reasons most performance articles never connect.
First, the average WordPress site loads between 15 and 40 JavaScript files on the front-end. Every analytics pixel, chat widget, consent banner, page builder, and plugin contributes scripts that compete for the main thread. INP doesn’t care which plugin caused the long task — it measures the delay and judges your page by it.
Second, WordPress still ships jQuery, and most themes and plugins still bind event handlers with jQuery(document).on(‘click’, …). jQuery’s event delegation runs synchronously on the main thread. On a page with a bloated DOM, a single click handler can chew through 300ms before the browser paints anything.
Third, WooCommerce. Variation swatches, quantity steppers, cart fragment AJAX, mini-cart updates, and every block-based checkout field register event handlers that cascade work on the main thread. If you’re not familiar with how WooCommerce enqueues these scripts under the hood, our guide to WordPress hooks and filters covers the enqueue flow in detail. A single click on a variation swatch can easily trigger 10 separate event listeners, a wc_variation_form rebuild, and a cart fragment refresh — while the user stares at an unresponsive page.
The 200ms threshold isn’t generous. You get one-fifth of a second from the moment a finger lifts off the screen until the browser must show the next frame. On a mid-range Android phone with 4x CPU throttling (which is what Google uses to model real users), that budget evaporates fast.
Stop Trusting Lab Tests. Get Real Field Data First.
Most WordPress performance articles start with “run PageSpeed Insights”. That’s fine for LCP and CLS, but for INP it’s misleading. Lab INP estimates are synthetic — they simulate interactions on a cold page load. Real INP is field data, collected from actual Chrome users via the Chrome User Experience Report (CrUX). These numbers diverge wildly.
Here’s a real example from a Cloudways-hosted store we audited last month. Lab INP in PageSpeed: 180ms. CrUX INP from the last 28 days: 412ms. That’s a full “Poor” rating sitting behind a green lab score. The gap existed because the worst interactions happened after scroll, after consent banner acceptance, and during live search — none of which PSI ever simulates.
To get the truth, use three tools together.
Start with the Search Console Core Web Vitals report. It shows CrUX data for your real traffic, segmented by URL group. This is the number Google actually uses for ranking. Don’t argue with it.
Next, install the web-vitals.js attribution build on your site. The attribution build doesn’t only hand back the INP number — it tells you which element was interacted with, which event type (pointerup, click, keydown), and the exact phase that ate the budget. Drop this in your theme’s footer:
<script type="module">
import { onINP } from 'https://unpkg.com/web-vitals@5/dist/web-vitals.attribution.js?module';
onINP((metric) => {
const attr = metric.attribution;
const payload = {
value: metric.value,
rating: metric.rating,
target: attr.interactionTarget,
type: attr.interactionType,
inputDelay: attr.inputDelay,
processingDuration: attr.processingDuration,
presentationDelay: attr.presentationDelay,
loafs: attr.longAnimationFrameEntries?.length || 0
};
navigator.sendBeacon('/wp-json/swiftly/v1/inp-log', JSON.stringify(payload));
}, { reportAllChanges: false });
</script>
That beacon posts to a custom REST route you register from a small MU-plugin. You now have your own RUM pipeline, tied to the exact DOM selector that fired the bad interaction. No more guessing whether it was the mobile menu or the cart button. If you need a refresher on building secure, cached REST endpoints, our post on custom REST API endpoint authentication and caching walks through the pattern end-to-end.
Finally, use Chrome DevTools Performance panel for deep dives. The Interactions lane in the timeline shows each interaction’s full duration split into input delay, processing time, and presentation delay. Set CPU throttling to 4x before you record — unthrottled desktop timings bear no resemblance to what your mobile users experience.
The Three Phases of a Slow Interaction
Before you fix anything, you need to know which phase is eating your budget. Every interaction has three parts. Get this wrong and you’ll spend a week tuning the thing that isn’t the problem.
Input delay is the time between the user’s click and when your event handler actually starts running. High input delay means the main thread was busy with something else — usually a third-party script finishing its work, or a long task from your own code that hadn’t yielded.
Processing time is how long your event handlers run. jQuery chains, React renders, DOM queries, synchronous fetches — all of it stacks here.
Presentation delay is the time between your handler finishing and the browser actually painting the next frame. Big DOM, heavy CSS recalculation, layout thrashing in your handler, and forced synchronous layout all bloat this.
Each phase has different fixes. Don’t apply a processing-time fix to an input-delay problem.
Fixing Input Delay: Stop Blocking the Main Thread Before the Click Even Happens
Input delay is almost always caused by third-party scripts running on page load or during idle moments. When a user clicks mid-execution, the browser can’t start your handler until whatever’s running finishes.
The standard WordPress advice here is “use delay JavaScript”. WP Rocket, Perfmatters, and FlyingPress all offer this feature, and they work by wrapping script tags and only loading them after first user interaction. That helps LCP, but it actively hurts INP because the first interaction — often a click — triggers a flood of script loading that blocks the handler the user fired to get there.
The better approach in 2026 is to be surgical. Load the scripts you need during page load, but offload the ones that don’t need to block the main thread. For Google Tag Manager, Meta Pixel, and similar trackers, move them to Cloudflare Zaraz or server-side GTM. Zaraz runs the tags at the edge, so your user’s CPU never sees the code at all. We’ve measured INP improvements of 80–150ms on WooCommerce stores from this single change alone.
For scripts you can’t offload, mark them correctly. Use async for anything that doesn’t need the DOM, defer for anything that does, and stop using strategy hints unless you understand what they’re doing to the execution order. In WordPress, you can enforce this on any enqueued script:
add_filter('script_loader_tag', function ($tag, $handle) {
$async = ['my-analytics', 'intercom-lazy'];
$defer = ['my-theme-main', 'swiftly-interactions'];
if (in_array($handle, $async, true)) {
return str_replace(' src', ' async src', $tag);
}
if (in_array($handle, $defer, true)) {
return str_replace(' src', ' defer src', $tag);
}
return $tag;
}, 10, 2);
The real kill though is the WordPress heartbeat. On logged-in front-end pages — which is exactly what happens when a customer has an account on your WooCommerce store — the heartbeat fires an admin-ajax request every 15 seconds. Each fire triggers a scripted response that can block interactions for 40–80ms on a cheap phone. Most WordPress sites never change the default.
Kill it or throttle it from an MU-plugin — and while you’re at it, audit any other background activity your site runs, because WordPress cron jobs can also hit the main thread on logged-in visits:
<?php
// wp-content/mu-plugins/swiftly-heartbeat.php
add_action('init', function () {
if (!is_admin()) {
wp_deregister_script('heartbeat');
}
}, 1);
add_filter('heartbeat_settings', function ($settings) {
$settings['interval'] = 60;
return $settings;
});
Full deregistration on the front-end is fine for most stores. If you rely on real-time features (chat, concurrent editing notices, session locking), bump the interval to 60 seconds instead. The default 15 is reckless.
Fixing Processing Time: Break Up Long Tasks Like You Mean It
Processing time is where the real developer work happens. If your INP attribution shows processingDuration above 100ms, you’ve got JavaScript that’s doing too much in a single synchronous chunk.
The classic WordPress offender is a theme’s main.js file that attaches twenty event listeners to the same element, runs DOM queries in a loop, and rebuilds a DOM subtree from scratch on every click. Here’s a real pattern we pulled from a popular theme last month, cleaned up for clarity:
jQuery(document).on('click', '.filter-item', function () {
const filter = jQuery(this).data('filter');
const products = jQuery('.product-card');
products.each(function () {
const categories = jQuery(this).data('categories').split(',');
if (filter === '*' || categories.includes(filter)) {
jQuery(this).fadeIn(400);
} else {
jQuery(this).fadeOut(400);
}
});
jQuery('.filter-item').removeClass('active');
jQuery(this).addClass('active');
updateCounterBadges();
trackFilterClick(filter);
});
On a category page with 48 products, that handler ran at 340ms on a mid-tier Android. Every frame the browser tried to animate while the jQuery loop ran, layout thrashing killed painting. INP reported 520ms.
Here’s the rewrite that passes INP comfortably:
const filterContainer = document.querySelector('.product-filters');
const productGrid = document.querySelector('.product-grid');
// Pre-read data once, not on every click.
const products = [...productGrid.querySelectorAll('.product-card')].map(el => ({
el,
categories: el.dataset.categories.split(',')
}));
filterContainer.addEventListener('click', async (event) => {
const target = event.target.closest('.filter-item');
if (!target) return;
// Update UI state immediately — cheap work first, paint fast.
filterContainer.querySelector('.active')?.classList.remove('active');
target.classList.add('active');
const filter = target.dataset.filter;
// Yield so the browser can paint the active state change.
await yieldToMain();
// Batch DOM writes inside a single layout pass.
const toShow = [];
const toHide = [];
for (const product of products) {
if (filter === '*' || product.categories.includes(filter)) {
toShow.push(product.el);
} else {
toHide.push(product.el);
}
}
// requestAnimationFrame batches mutations into one layout.
requestAnimationFrame(() => {
toShow.forEach(el => el.classList.remove('is-hidden'));
toHide.forEach(el => el.classList.add('is-hidden'));
});
// Defer analytics — it doesn't need to block the interaction.
if ('requestIdleCallback' in window) {
requestIdleCallback(() => trackFilterClick(filter));
} else {
setTimeout(() => trackFilterClick(filter), 0);
}
});
// yieldToMain polyfill — prefers scheduler.yield() in newer Chrome.
function yieldToMain() {
if ('scheduler' in window && 'yield' in window.scheduler) {
return window.scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
Three things matter in this rewrite, and they apply to every WordPress interaction you touch.
Paint cheap UI changes first. The class swap on the active filter button is the visual feedback the user actually sees. Doing it before the heavy loop means the user’s perceived responsiveness is under 50ms, even though the full filter operation takes longer.
Yield to the main thread. scheduler.yield() is now supported in Chrome 129+ (stable since late 2024) and tells the browser “I’m pausing — go paint and handle other tasks, then come back to me.” That’s the entire point of INP optimisation: the “Next Paint” part of Interaction to Next Paint. If you don’t yield, there’s no next paint.
Separate reads from writes. The original code read data-categories from every DOM node inside a loop that was also triggering fadeIn/fadeOut. That’s classic layout thrashing — every write invalidates the next read, forcing synchronous layout recalculation. Reading everything once upfront and batching writes inside requestAnimationFrame kills the thrash.
If you still use jQuery because a plugin or legacy theme depends on it, you can’t always avoid it. But you can wrap your own code with vanilla addEventListener even inside a jQuery-loaded site — they don’t conflict. Drop jQuery for your own interactions and let the old plugins keep it.
Fixing Presentation Delay: Your DOM Is Probably Too Big
Presentation delay is the time between your handler finishing and the browser painting the next frame. If this is your worst phase, the problem isn’t JavaScript speed — it’s what the browser has to do after your code runs.
The two big offenders on WordPress are DOM size and expensive CSS.
DOM size matters because every style recalculation scales with the number of nodes the browser has to process. Page builders are the main culprit here. A single Elementor or Divi page regularly hits 3,000+ DOM nodes. We’ve seen Divi templates with 5,800 nodes on a simple product page. Google’s own guidance puts 1,500 as the warning threshold. Every click triggers a style recalculation that touches every node that matches any changed selector — so body.menu-open causing cascading recalcs across 5,000 nodes is a 200ms hit on its own.
Check your DOM size in the DevTools console:
document.querySelectorAll('*').length
If that returns more than 1,500, you have a structural problem that no JavaScript optimisation will fix. Rebuilding key templates in Gutenberg (which ships far lighter markup) is genuinely the right answer. We’ve migrated agency clients from Elementor to Gutenberg blocks and watched INP drop from 480ms to 140ms overnight on the same hosting, same plugins, same everything else.
The second presentation problem is CSS containment. Tell the browser which parts of the page can be isolated for layout purposes with contain: layout style or content-visibility: auto. On a category archive with dozens of product cards, adding this to the card wrapper cut our test store’s paint time by 40%:
.product-card {
contain: layout style paint;
content-visibility: auto;
contain-intrinsic-size: 0 420px;
}
content-visibility: auto tells the browser to skip rendering work for off-screen cards entirely. contain-intrinsic-size reserves the space so CLS stays at zero. This is 2026 CSS and it works everywhere your customers are.
The WooCommerce INP Hit List
WooCommerce has its own specific INP failures that aren’t covered by general WordPress optimisation. Here are the ones we see on every store audit.
Cart fragments on non-cart pages. Pre-WooCommerce 7.8 this ran everywhere. Post-7.8 it only runs on pages with a mini-cart widget, but most themes include one in the header, so it still runs everywhere for most stores. The wc-ajax=get_refreshed_fragments call blocks the main thread every time the cart updates. On a live store with a customer clicking through products, that’s several blocks per session.
If you don’t show a mini-cart count in the header, dequeue it entirely:
add_action('wp_enqueue_scripts', function () {
if (function_exists('is_woocommerce')) {
wp_dequeue_script('wc-cart-fragments');
}
}, 11);
If you do show a mini-cart count but it only needs to update after add-to-cart (not after every AJAX call), replace cart fragments with a custom endpoint that returns only the count and total:
add_action('rest_api_init', function () {
register_rest_route('swiftly/v1', '/cart-mini', [
'methods' => 'GET',
'callback' => function () {
return [
'count' => WC()->cart->get_cart_contents_count(),
'total' => WC()->cart->get_cart_total(),
];
},
'permission_callback' => '__return_true',
]);
});
Variation swatches. The default wc_variation_form rebuilds the entire variation UI on every click. On a product with 30 variations, that’s hundreds of DOM operations per interaction. The cleanest fix is to render swatches server-side with the variation data attached to each button, then only update the price and availability via a lightweight fetch on click — never rebuild the form.
Quantity steppers. Several popular themes bind jQuery handlers to + and – quantity buttons that trigger cart fragment refreshes on every click. Debounce the update and only ping the server when the user stops clicking for 400ms:
let qtyTimer;
document.querySelectorAll('.qty').forEach(input => {
input.addEventListener('input', () => {
clearTimeout(qtyTimer);
qtyTimer = setTimeout(() => {
document.querySelector('[name="update_cart"]')?.removeAttribute('disabled');
document.querySelector('[name="update_cart"]')?.click();
}, 400);
});
});
Live search. Every WooCommerce store that enables AJAX live search runs a debounced keystroke handler that fetches product results. If the debounce is under 250ms, INP fails on keydown. 350ms is the sweet spot — fast enough to feel responsive, slow enough to not block typing.
The 2026 Kill List: What to Stop Using
SwiftlyWP is opinionated about this, and being honest is more useful than polite hedging. If you’re serious about passing INP in 2026, some things have to go.
Kill bloated page builders for production sites. Elementor Pro with multiple third-party addons routinely ships 180KB of JavaScript per page and builds DOMs in the thousands of nodes. Gutenberg with native blocks does the same job with 30KB of JavaScript and a cleaner DOM. Bricks Builder is a reasonable middle ground if you need visual editing and can’t move to Gutenberg.
Kill live chat widgets that inject on every page load. Intercom, Tawk.to, Drift, and HubSpot Messages all load 150–300KB of JavaScript on every pageview. If chat matters, load it on-demand from a static button that only injects the widget when clicked. You keep the functionality and lose the INP hit.
Kill jQuery Migrate. WordPress stopped shipping it by default in 5.5, but many themes re-enqueue it for backwards compatibility. Confirm nothing in your stack needs it, then remove it:
add_action('wp_default_scripts', function ($scripts) {
if (!is_admin() && isset($scripts->registered['jquery'])) {
$scripts->registered['jquery']->deps = array_diff(
$scripts->registered['jquery']->deps,
['jquery-migrate']
);
}
});
Kill unthrottled resize and scroll handlers. A surprising number of WordPress themes bind window.addEventListener(‘scroll’, …) without throttling. On a mobile device, that fires 60 times a second. Wrap every scroll handler in requestAnimationFrame or a throttle function. No exceptions.
Kill old sliders. Slider Revolution and Layer Slider still dominate WordPress sites and still ship client-side JavaScript that hasn’t been rewritten for the main-thread budgets of 2026. Native CSS scroll-snap carousels do the same job with zero JavaScript.
Your 30-Minute WordPress INP Audit
Here’s the exact sequence to run on any WordPress site to find and fix INP issues fast.
Open Search Console and check the Core Web Vitals report for Poor INP URLs. Pick three that represent different page types — homepage, product page, archive page.
Open each URL in Chrome with DevTools Performance panel open. Set CPU throttling to 4x and network to Fast 4G. Record a session, click the elements a real user would click, stop recording.
Find the Interactions lane in the timeline. Identify the longest interaction and click it. Chrome will show you the input delay, processing time, and presentation delay split.
If input delay dominates, look at what was running on the main thread before the click. Usually a third-party script or the heartbeat API. Dequeue, defer, or offload.
If processing time dominates, click the Long Task frame in the timeline and expand the bottom-up view. Find the event handler that’s taking the most self time. Rewrite it with the patterns from earlier in this guide — yield to main, batch DOM writes, defer analytics.
If presentation delay dominates, check DOM size in the console. If it’s over 1,500 nodes, you have a structural problem. Add content-visibility: auto to off-screen card components as a first pass. Plan a template refactor for a permanent fix.
Deploy the MU-plugin snippets from this guide: heartbeat throttling, cart fragments dequeue if not needed, jQuery Migrate removal if you can, script loader filters for async/defer.
Install the web-vitals.js attribution snippet and monitor field data for two weeks. Watch for the worst interaction targets and fix them one by one, starting with the highest-traffic pages.
If you’d rather have this done properly by experts, our WordPress Speed & Performance Optimization service starts at $499 and includes a full Core Web Vitals audit with INP-specific debugging and fixes.