Last Tuesday a client pinged me on Slack with a screenshot from PageSpeed Insights: a WordPress storefront sitting at LCP 1.9s, CLS 0.02, INP 612ms. Two green, one bright red. They had already done the obvious stuff — switched to a lightweight theme, enabled WP Rocket, swapped to a Cloudflare-fronted host. The Lighthouse report kept whispering the same thing: "Avoid long main-thread tasks." Their developer had read that line for three weeks and had no idea what to do with it.
I have now run this exact diagnosis enough times in 2026 that I can almost guess the offender before opening DevTools. The Chrome team made INP a Core Web Vital in March 2024 (replacing FID), and the field-data definition is unforgiving — Google takes the 98th-percentile interaction from your CrUX bucket, which means one slow click on a mobile device with a flaky CPU will wreck your aggregate score. As of the May 2026 CrUX dataset, roughly 41% of WordPress origins still fail the 200ms "good" threshold, and a stubborn ~7% sit above 500ms. That 500ms band is where this article lives.
Below is the offender list I work through in order, plus the defer/idle pattern I now ship on every audit. None of it requires uninstalling plugins — most clients have political reasons they can't rip out their analytics or their CRM widget, and the fix has to work inside that constraint.
How I actually measure INP before touching anything
PageSpeed Insights gives you the field number, but it won't tell you which interaction blew the budget. For that I open the page in Chrome, throttle CPU to 4x slowdown (this approximates a mid-range Android in 2026), and use the Performance panel's new "Interactions" track. The Chrome team documented this workflow in detail on web.dev's INP overview and it remains the canonical reference.
The pattern I look for is a single interaction (click, tap, keypress) followed by a thick yellow band of scripting work before the next paint. If that band is taller than 500ms, you have an INP problem. If the band fires immediately on page load but no interaction is happening, you have a TBT problem instead — those are different bugs with different fixes, and people conflate them constantly.
One quick sanity check I always run in the console:
// Paste into DevTools console, then click around the page
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn(
`Slow interaction: ${entry.name} took ${entry.duration}ms`,
entry.target
);
}
}
}).observe({ type: 'event', durationThreshold: 200, buffered: true });
That snippet uses the Event Timing API (shipped in Chrome 85, still the foundation of INP measurement). When a slow click happens, it logs the actual DOM target — which is gold, because it usually points straight at the plugin shortcode wrapper that's hogging the main thread.
The 7 WordPress script offenders I find again and again
I keep a running tally across audits. As of mid-2026, this is the order of frequency. Numbers one through three account for maybe 70% of the 500ms+ cases I see.
1. Elementor Pro's hover/animation handlers
Elementor 3.27 (the current stable as of May 2026) still attaches mousemove and scroll listeners eagerly for any widget with motion effects. On a long landing page with 20+ widgets, the first scroll after page load can synchronously compute transform matrices for every one of them. I've seen this single pattern produce a 480ms interaction on an M1 MacBook — on a real Android, it doubles.
2. WooCommerce's wc-add-to-cart-variation.js
The variation form rebinds every change listener on every variation swap. With 15+ attributes it gets slow fast. There is an open issue on the WooCommerce GitHub tracking the rebind cost, and as of WooCommerce 9.4 it's still unresolved.
3. Third-party chat widgets (Intercom, Drift, HubSpot, Tawk.to)
These ship 200-400KB of compressed JavaScript that runs an entire React tree off the main thread frame. The widget itself isn't interacted with — but its initial mount blocks the first real click anywhere on the page.
4. Google Tag Manager containers with 30+ tags
Each tag adds a synchronous dataLayer push handler. On a click, GTM walks every trigger to check matches. I audited one site with 67 tags whose every click took 380ms just inside GTM.
5. Cookie consent banners (CookieYes, Complianz, OneTrust)
These run a blocking script that scans every other script tag for category attributes, then conditionally loads them. The scan itself is fine — the conditional load on accept-click is what kills INP, because it parses and executes 5-10 deferred scripts in one burst.
6. Rank Math / Yoast's frontend schema generators
Modern SEO plugins inject JSON-LD via JavaScript on some configurations. The serialization runs on DOMContentLoaded but holds the main thread for 80-150ms on big posts.
7. LiteSpeed Cache's lazy-load image observer
The IntersectionObserver callback also rewrites srcset attributes — a O(n) DOM mutation that runs on every scroll-triggered batch.
The defer + requestIdleCallback pattern that fixes most of them
You cannot remove these plugins. The client paid for them or the marketing team owns them or the legal team mandated them. What you can do is delay their execution until after the first meaningful interaction, then break their work into idle-callback chunks.
I drop the following into a small mu-plugin (must-use plugin) so it loads before everything else. The key idea: intercept script tags with known offenders, swap their src for a data attribute, then re-attach after a user interaction or after 4 seconds, whichever comes first.
<?php
// File: wp-content/mu-plugins/inp-script-defer.php
add_action('wp_head', function () {
$deferred_hosts = [
'widget.intercom.io',
'js.hs-scripts.com',
'embed.tawk.to',
'cdn.cookieyes.com',
];
?>
<script>
(function () {
const hosts = <?php echo wp_json_encode($deferred_hosts); ?>;
const isOffender = (src) => hosts.some(h => src && src.includes(h));
// Intercept script tags before they execute
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.tagName === 'SCRIPT' && isOffender(node.src)) {
const saved = node.src;
node.removeAttribute('src');
node.dataset.deferredSrc = saved;
}
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
const rehydrate = () => {
document.querySelectorAll('script[data-deferred-src]').forEach(s => {
const clone = document.createElement('script');
clone.src = s.dataset.deferredSrc;
clone.async = true;
document.head.appendChild(clone);
s.remove();
});
};
['pointerdown', 'keydown', 'touchstart'].forEach(evt =>
addEventListener(evt, rehydrate, { once: true, passive: true })
);
setTimeout(rehydrate, 4000);
})();
</script>
<?php
}, 1);
That covers the third-party chat-widget category cleanly. For Elementor and WooCommerce, you can't intercept the script — it's a core dependency — but you can wrap their handlers in requestIdleCallback using a tiny wrapper:
// Inject this AFTER Elementor frontend loads
(function () {
const original = jQuery.fn.on;
jQuery.fn.on = function (events, ...rest) {
if (typeof events === 'string' && /scroll|mousemove|resize/.test(events)) {
const handler = rest[rest.length - 1];
if (typeof handler === 'function') {
rest[rest.length - 1] = function (...args) {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => handler.apply(this, args), { timeout: 100 });
} else {
setTimeout(() => handler.apply(this, args), 0);
}
};
}
}
return original.call(this, events, ...rest);
};
})();
This wraps every jQuery scroll, mousemove, and resize handler in requestIdleCallback, which the browser schedules in idle frames. The MDN entry for requestIdleCallback explains the budget semantics — you get whatever's left over after the browser finishes rendering, capped by your timeout. For passive motion effects this is exactly right; the user won't notice a 16ms delay on a scroll-triggered parallax.
One caveat: do not wrap click or submit handlers this way. Those are direct responses to user intent and deferring them makes the page feel broken. The wrapper above is intentionally scoped to scroll/move/resize only.
Measuring the win
On the storefront I opened this article with, the before/after looked like this after one deploy cycle:
- INP (75th percentile, mobile): 612ms to 184ms
- TBT (lab): 890ms to 240ms
- LCP: unchanged at 1.9s (it was already fine)
- Bounce rate, week-over-week: down 11%
The CrUX update lag is real — Google's field data refreshes on a 28-day rolling window, so PageSpeed Insights will keep showing the old red score for almost a month after the fix ships. Use the lab number plus a tool like the web-vitals JS library with a real-user monitoring endpoint to confirm the win immediately. I usually wire it into a small endpoint that batches sends every 30 seconds.
If you want a deeper read on the load-order problem that often causes the secondary CLS regression after this fix, I wrote about that in preloading WordPress fonts without breaking LCP. And for the underlying main-thread cost of jQuery itself, there's a longer treatment in the jQuery main-thread budget piece.
Caveats I have to mention
The mu-plugin script-interception trick has two failure modes. First, if the offender script is server-rendered into the initial HTML (not appended dynamically), the MutationObserver won't catch it — you need to also scan document.querySelectorAll('script[src]') on DOMContentLoaded. Second, some chat widgets check for the existence of their global object on load and refuse to reinitialize; for Intercom specifically, you need to call window.Intercom('reattach_activator') after the rehydrate.
The jQuery wrapper trick is monkey-patching, which is fragile. If a plugin updates and starts using native addEventListener instead of jQuery.fn.on, the wrapper silently stops doing anything. I review every audited site's plugin update log monthly and re-run the INP measurement after major updates. There's no clean alternative — the WordPress ecosystem is built on jQuery and will be for years yet.
Finally: if your INP is over 1000ms, none of this will be enough. At that level you almost always have a synchronous database query firing on click via admin-ajax.php, and the fix is server-side, not client-side. Check your Query Monitor output before going down the JavaScript rabbit hole.
FAQ
Does this work with Full Site Editing themes in WordPress 6.7+?
Yes. The mu-plugin runs in wp_head at priority 1, which fires before any block theme rendering. I've tested it against Twenty Twenty-Six and the Kadence block theme without issues. The jQuery wrapper obviously only helps if jQuery is loaded, which most block themes still do via classic-theme-styles compatibility.
Will this break Google Analytics or conversion tracking?
No, but you should test. Deferring GTM by 4 seconds means a bounce that happens in the first 4 seconds won't be recorded. For most sites that's fine (a sub-4-second bounce is almost certainly a bot or a misclick), but for ad-heavy landing pages you may want to drop the timeout to 1500ms or trigger rehydrate on the first scroll event in addition to pointerdown.
Why not just use WP Rocket's "Delay JavaScript Execution" feature?
You can, and for many sites it's enough. The reason I roll my own is that WP Rocket's delay applies to all scripts in its exclusion list — it doesn't selectively wrap handler functions in requestIdleCallback, which is what actually fixes the Elementor scroll case. The two approaches are complementary; I often use WP Rocket's delay for third-party scripts and the wrapper above for jQuery handlers.
Is INP going to change again before 2027?
Probably not the metric itself, but Google has signaled that the "good" threshold may tighten from 200ms to 150ms once the median origin clears 200ms in CrUX. That's still a few quarters out based on the current improvement curve. Fix the 500ms case now and the 150ms case will be a tuning exercise, not a rebuild.
If you fix one thing this week, make it the script-defer mu-plugin. It's 30 lines, ships in a single deploy, and on every audit I've run it moves INP by at least 200ms with zero risk to plugin functionality. That's the cheapest Core Web Vitals win available on WordPress in 2026.