Since INP replaced FID in the Core Web Vitals (on March 12, 2024), many once-"green" WordPress sites have turned red. INP measures real responsiveness to interactions, and on WordPress, plugins and third-party JavaScript are what wreck performance. Here's how to diagnose and optimize INP, with concrete code.
I'm Tommy Bordas, a full-stack developer with 10+ years of experience based in Nantes, specialized in WordPress/WooCommerce and web performance. This article distills what I apply during client audits to get INP back into the green.
INP vs FID: what actually changed
FID (First Input Delay) measured one thing only: the delay between the user's first interaction and the moment the browser started processing it. It ignored the rest of the visit, and it didn't even account for the handler's processing time or the rendering that follows. It was a forgiving metric, easy to keep green.
INP (Interaction to Next Paint) is far stricter. It observes every interaction in the visit (clicks, taps, keypresses), measures the full latency for each (input delay + processing time + presentation delay up to the next visual paint) and keeps (roughly) the worst one. So a single slow handler is enough to degrade the whole page's score.
| Metric | Measures | Scope |
|---|---|---|
| FID (deprecated) | Input delay of the 1st interaction | A single interaction |
| INP | Full latency up to the next paint | Worst interaction of the visit |
In concrete terms: an "FID-green" site could hide a mobile menu that took 400 ms to open on the 3rd tap. With INP, that menu tips the page into the red.
Takeaway: the FID → INP switch, made official on March 12, 2024, is not a simple rename. We moved from measuring the first contact to measuring sustained responsiveness across the whole session. A blocked main thread, once tolerated, now costs you rankings in Google.
The official Core Web Vitals thresholds
Google evaluates each metric at the 75th percentile of page loads, separately for mobile and desktop. To pass into the green, 75% of visits must meet the "good" threshold.
| Metric | Good | Needs work | Poor |
|---|---|---|---|
| INP | ≤ 200 ms | 200-500 ms | > 500 ms |
| LCP | ≤ 2.5 s | 2.5-4 s | > 4 s |
| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 |
INP is today the hardest Core Web Vital to hold on WordPress, precisely because it depends on the amount of JavaScript executed on the main thread, an area where WordPress excels… in the wrong way.
Why WordPress suffers most with INP
An average production WordPress site loads JavaScript from 15 to 25 plugins: sliders, popups, analytics, chat, A/B testing, forms, cookies, social… each adding event listeners and long tasks (long tasks > 50 ms) on the main thread. Three factors make it worse:
- Ever-present jQuery. Many themes and plugins still rely on jQuery and its plugins, which run synchronously and monopolize the thread.
- Uncontrolled third-party scripts. Tag managers, ad pixels, chat widgets: their code is injected as-is, with no chunking, and fires long tasks exactly when the user wants to click.
- Bloated DOM. Page builders (Elementor, Divi, WPBakery) generate DOM trees of several thousand nodes. The larger the DOM, the more expensive each style recalculation and each render triggered by an interaction becomes.
INP diagnosis: lab vs field
First rule: never optimize blind. And above all, distinguish two kinds of data that don't say the same thing.
| Type | Source | What it measures | Limit |
|---|---|---|---|
| Lab (synthetic) | Lighthouse, DevTools, PSI "Analyze" tab | One controlled run, on a given machine | Doesn't reflect your real users |
| Field (CrUX) | Chrome UX Report, PageSpeed Insights, RUM | Real interactions over the last 28 days | Aggregation lag, no per-session detail |
Lighthouse does not give an INP score in the lab, because INP needs real human interactions to exist. The score that matters for SEO comes from the field (CrUX), shown at the top of PageSpeed Insights. The lab is for reproducing and debugging; the field is for validating.
Spotting long tasks with PerformanceObserver
The PerformanceObserver API reveals, from page load, the tasks that block the main thread for more than 50 ms.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long task: ${Math.round(entry.duration)}ms`, entry);
}
}
}).observe({ type: 'longtask', buffered: true });
Attributing INP to a specific interaction
To find out which element and which phase (input delay, processing, presentation) is dragging INP down, the official web-vitals library exposes full attribution:
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
console.log('INP', metric.value, 'ms');
console.log('Target:', a.interactionTarget);
console.log('Input delay:', a.inputDelay);
console.log('Processing:', a.processingDuration);
console.log('Presentation:', a.presentationDelay);
}, { reportAllChanges: true });
Cross-reference this data with Chrome DevTools' Performance tab at 4× CPU throttling and Slow 4G network (to simulate a low-end mobile), and with PageSpeed Insights' INP diagnostics. You get the exact culprit: the script, the handler, the phase.
Fix #1: break up long tasks (yielding)
A function that processes 1,000 items at once monopolizes the thread and drives INP up. Split it by yielding back to the browser between batches, which lets it handle pending interactions.
async function processInChunks(items, handler) {
for (let i = 0; i < items.length; i++) {
handler(items[i]);
// Yield every 50 items to stay responsive
if (i % 50 === 0) {
await yieldToMain();
}
}
}
function yieldToMain() {
// scheduler.yield(): Chrome/Edge and Firefox (since August 2025).
// Not in Safari yet → setTimeout fallback.
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
scheduler.yield() beats the old setTimeout(0) trick: the continuation resumes with priority, ahead of other queued tasks, so your work doesn't get overtaken by other scripts. Be careful, though: the API is not yet Baseline (Safari doesn't implement it to date), hence the fallback above.
Fix #2: defer non-critical JavaScript
Anything not needed for the first render should load after the page is interactive. In PHP/WordPress, force the defer attribute on non-critical scripts via the script_loader_tag filter, placed in the theme's functions.php or a custom plugin.
add_filter('script_loader_tag', function ($tag, $handle) {
// Handles as registered via wp_enqueue_script()
$defer = ['chat-widget', 'analytics', 'ab-testing', 'social-share'];
if (in_array($handle, $defer, true) && strpos($tag, ' defer') === false) {
return str_replace(' src', ' defer src', $tag);
}
return $tag;
}, 10, 2);
defer downloads the script in parallel but only runs its code once the HTML is parsed, without blocking rendering or early interactions. Check the exact $handle values in the page source (view-source) or via wp_print_scripts.
Fix #3: delay third-party scripts until interaction
A chat box, a social widget or an interactive map has no reason to load before the user needs it. Trigger them on the first scroll, click or keypress, which removes their cost from the critical path and from the initial INP.
const loadOnInteraction = (loader) => {
const events = ['scroll', 'pointerdown', 'keydown'];
const fire = () => {
loader();
events.forEach((e) => window.removeEventListener(e, fire));
};
events.forEach((e) =>
window.addEventListener(e, fire, { once: true, passive: true })
);
};
loadOnInteraction(() => loadChatWidget());
On WordPress, the WP Rocket plugin ("Delay JavaScript execution" option) or Perfmatters automate this technique without code, script by script. Handy when you can't touch every third-party script by hand.
Fix #4: trim the DOM and event listeners
INP also climbs when the browser has to recalculate styles on a huge DOM on every interaction. Two levers:
- Reduce DOM size: limit nested page-builder sections, remove unnecessary wrappers. Aim for under 1,500 nodes; Lighthouse warns past ~800.
- Delegate events: instead of attaching a handler to 200 buttons, attach one to the parent container and read
event.target. Fewer listeners = less memory and shorter tasks.
// Delegation: one handler for a whole list
document.querySelector('.product-grid').addEventListener('click', (e) => {
const btn = e.target.closest('.add-to-cart');
if (btn) addToCart(btn.dataset.productId);
});
The INP-on-WordPress checklist
- Measure the field first (CrUX / PageSpeed Insights), not just the lab
- Audit long tasks (PerformanceObserver + DevTools 4× CPU / Slow 4G)
- Attribute each INP to an interaction (
web-vitals/attribution) - Remove or replace the heaviest JS plugins
-
deferall non-critical JavaScript (script_loader_tagfilter) - Load third-party scripts on first interaction
- Break up long work with
scheduler.yield()(+ Safari fallback) - Trim the DOM and delegate event listeners
- Re-measure after 28 days to let the field data update
Going further
On overall optimization of an online store (SQL queries, cart, fragment caching), see also: WooCommerce performance optimization.
Has your WordPress site gone red on Core Web Vitals, or is INP refusing to drop below 200 ms? Request a performance audit: I find the long tasks and fix them.