Fix INP in WordPress
Interaction to Next Paint (INP) measures the latency between a user's input -- a click, tap, or keypress -- and the moment the browser paints a visual response. Google's Good threshold is 200ms or under; anything above 500ms is Poor. WordPress sites frequently score in the 400-600ms range because they accumulate JavaScript from many sources: the theme, jQuery-dependent plugins, page builders, ad scripts, chat widgets, analytics, and consent management platforms. All of this JavaScript shares a single main thread. When a user clicks a menu item or submits a search query, the browser must wait for any running JavaScript task to complete before it can process the input and paint the response. Reducing the number, size, and execution time of JavaScript tasks directly reduces INP. These five fixes address the highest-impact causes specific to WordPress.
Expected results
Before
520ms
INP (Poor) -- heavy third-party scripts, large DOM, synchronous jQuery
After
160ms
INP (Good) -- deferred scripts, reduced DOM, lazy-initialized widgets
Step-by-step fix
Reduce third-party script impact
Third-party scripts from ad networks, live chat platforms, marketing automation, and analytics tools are among the most common sources of long tasks in WordPress. Unlike your own JavaScript, you cannot refactor third-party code -- but you can control when and how it loads. Scripts loaded synchronously in the <head> block all parsing and delay interactivity. Loading them with async or defer removes them from the critical path. WP Rocket's "Delay JavaScript Execution" feature goes further, deferring specified scripts until after the first user interaction, which keeps the main thread free during the most interaction-critical window.
<?php
// functions.php
// Step 1: Audit third-party scripts with Chrome DevTools
// Performance tab > Record page load
// In the flame chart, look for tasks from third-party origins
// Check "Third-party usage" section in Lighthouse for impact summary
// Step 2: Defer third-party scripts registered through WordPress
add_action( 'wp_enqueue_scripts', function() {
// Example: defer a live chat widget
wp_enqueue_script(
'live-chat',
'https://cdn.livechat-provider.com/widget.js',
array(), // no jQuery dependency needed
null,
array(
'strategy' => 'defer',
'in_footer' => true,
)
);
// Example: load HubSpot tracking asynchronously
wp_enqueue_script(
'hubspot-tracking',
'https://js.hs-scripts.com/YOUR_ID.js',
array(),
null,
array( 'strategy' => 'async' )
);
} );
// Step 3: For scripts not registered through wp_enqueue_scripts,
// add the defer/async attribute via a filter on script_loader_tag
add_filter( 'script_loader_tag', function( $tag, $handle, $src ) {
$defer_handles = array( 'google-tag-manager', 'facebook-pixel', 'hotjar' );
if ( in_array( $handle, $defer_handles, true ) ) {
return str_replace( '<script ', '<script defer ', $tag );
}
return $tag;
}, 10, 3 );
// WP Rocket: Performance > Delay JavaScript Execution
// Add third-party script URLs or keywords to delay until first interaction
Minimize DOM size under 1,500 nodes
Every DOM node adds to the time required for style recalculation after an interaction. When a user clicks a button, the browser must recalculate styles for the affected element and all potentially affected descendants and siblings. With 3,000 DOM nodes, a typical page builder template, this recalculation can take 80-200ms -- longer than Google's entire INP budget. Google's Lighthouse flags any page with over 1,500 total DOM nodes, over 32 DOM nodes deep, or parent nodes with more than 60 children. Page builders are the primary cause of DOM bloat in WordPress, but complex theme templates, nested shortcodes, and overly granular widget structures also contribute.
<?php
// Measure DOM size:
// Chrome DevTools > Console: document.querySelectorAll('*').length
// Lighthouse > Diagnostics > "Avoid an excessive DOM size"
// Good: under 800 nodes | Warning: 800-1500 | Poor: over 1500
// Common DOM-bloating patterns to eliminate in theme templates:
// Bad: unnecessary wrapper divs
// <div class="outer-wrapper">
// <div class="inner-wrapper">
// <div class="content-wrapper">
// <p>Text</p>
// </div>
// </div>
// </div>
// Good: semantic minimal structure
// <p class="content">Text</p>
// In page builder templates, reduce nesting:
// - Convert multi-column sections to CSS Grid instead of nested rows/columns
// - Replace visual composer shortcodes with native WordPress blocks
// - Remove decorative wrapper elements and use CSS pseudo-elements instead
// WordPress template: simplified hero section (fewer DOM nodes)
function mytheme_hero_section( $post_id ) {
$title = get_the_title( $post_id );
$image_id = get_post_thumbnail_id( $post_id );
$image_url = wp_get_attachment_image_url( $image_id, 'hero' );
$image_alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
// Direct semantic HTML with no unnecessary wrappers
printf(
'<section class="hero" style="background-image:url(%s)" aria-label="%s">
<h1 class="hero__title">%s</h1>
</section>',
esc_url( $image_url ),
esc_attr( $title ),
esc_html( $title )
);
}
// Targets: each page builder row = ~5-15 nodes. Eliminating 20 rows saves 100-300 nodes.
Defer non-critical JavaScript to footer
JavaScript files loaded in the <head> without defer or async block HTML parsing and delay the point at which the browser can respond to user interactions. Even with defer, scripts that are large or complex create long tasks during execution that block input handling. WordPress 6.3 introduced a strategy argument for wp_register_script() and wp_enqueue_script() that automatically adds the defer attribute and moves the script to the document footer, replacing the older boolean in_footer pattern. Scripts moved to the footer with defer execute after HTML parsing is complete, preserving main-thread availability during the critical early interaction window.
<?php
// functions.php
// WordPress 6.3+: strategy parameter replaces the boolean in_footer
add_action( 'wp_enqueue_scripts', function() {
// Theme main script: defer to footer
wp_enqueue_script(
'mytheme-main',
get_theme_file_uri( 'js/main.js' ),
array( 'jquery' ), // dependency on jQuery
wp_get_theme()->get( 'Version' ),
array(
'strategy' => 'defer',
'in_footer' => true,
)
);
// Navigation script: defer (no jQuery dependency)
wp_enqueue_script(
'mytheme-nav',
get_theme_file_uri( 'js/navigation.js' ),
array(),
wp_get_theme()->get( 'Version' ),
array( 'strategy' => 'defer' )
);
// Contact form validation: defer until after initial paint
wp_enqueue_script(
'mytheme-forms',
get_theme_file_uri( 'js/forms.js' ),
array(),
'1.0.0',
array(
'strategy' => 'defer',
'in_footer' => true,
)
);
} );
// For WordPress < 6.3: use the boolean in_footer param + script_loader_tag filter
// wp_enqueue_script( 'handle', $src, $deps, $ver, true );
// Then add defer via the script_loader_tag filter shown in Step 1
Optimize jQuery event handlers
WordPress bundles jQuery because many plugins and themes depend on it. jQuery is not inherently slow, but common jQuery patterns used in older WordPress themes and plugins create performance problems at scale. Event delegation attached to $(document) means every click anywhere on the page runs through the handler function, even for events unrelated to the handler's target. Synchronous DOM reads like $(element).offset() or $(element).height() inside scroll or resize event listeners cause forced layout recalculation on every event, which blocks the main thread. Replacing these patterns with modern equivalents consistently improves INP by 50-150ms in profiles of real WordPress sites.
// Before: jQuery event delegation on document root (slow)
// Every click anywhere on the page runs this function
jQuery(document).on('click', '.accordion-toggle', function(e) {
jQuery(this).closest('.accordion').find('.accordion-content').slideToggle();
});
// After: event delegation on the closest ancestor (fast)
// Only clicks inside .accordion-list trigger this function
document.querySelector('.accordion-list')?.addEventListener('click', (e) => {
const toggle = e.target.closest('.accordion-toggle');
if (!toggle) return;
const content = toggle.closest('.accordion').querySelector('.accordion-content');
content.hidden = !content.hidden;
toggle.setAttribute('aria-expanded', !content.hidden);
});
// Before: reading layout properties inside scroll handler (causes layout thrash)
jQuery(window).scroll(function() {
var scrollTop = jQuery(window).scrollTop(); // forced layout recalculation
jQuery('.sticky-header').css('top', scrollTop > 100 ? '0' : '-80px');
});
// After: use passive scroll listener + requestAnimationFrame
let lastScrollY = 0;
const header = document.querySelector('.sticky-header');
window.addEventListener('scroll', () => {
lastScrollY = window.scrollY; // no layout recalculation
}, { passive: true }); // passive: true prevents scroll blocking
requestAnimationFrame(function update() {
header.classList.toggle('is-sticky', lastScrollY > 100);
requestAnimationFrame(update);
});
// Before: jQuery animation that modifies DOM on every frame
jQuery('.banner').animate({ opacity: 1 }, 600);
// After: CSS transition triggered by class (GPU-accelerated, no JS overhead)
// CSS: .banner { opacity: 0; transition: opacity 600ms ease; }
// CSS: .banner.is-visible { opacity: 1; }
document.querySelector('.banner').classList.add('is-visible');
Use IntersectionObserver for heavy widgets
WordPress sites frequently embed content-rich widgets: Google Maps, calendar plugins, comment systems, interactive sliders, and WooCommerce product carousels. Initializing all of these on page load executes large amounts of JavaScript during the period when users are most likely to interact with the page. The IntersectionObserver API provides a performant way to detect when an element enters the viewport and defer initialization until that moment. Unlike scroll event listeners, IntersectionObserver runs asynchronously off the main thread and does not block input handling. For widgets entirely below the fold, lazy initialization eliminates their initialization cost from the interaction-critical window entirely.
// Pattern: lazy-initialize any heavy widget when it enters the viewport
// 1. Google Maps: load script and initialize only when map container is visible
function initLazyMap() {
const mapContainers = document.querySelectorAll('[data-lazy-map]');
if (!mapContainers.length || !('IntersectionObserver' in window)) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
observer.unobserve(entry.target);
const container = entry.target;
const lat = parseFloat(container.dataset.lat);
const lng = parseFloat(container.dataset.lng);
// Load the Maps script only when needed
const script = document.createElement('script');
script.src = 'https://maps.googleapis.com/maps/api/js?key=YOUR_KEY&callback=initMap';
script.defer = true;
document.head.appendChild(script);
window.initMap = () => {
new google.maps.Map(container, { center: { lat, lng }, zoom: 14 });
};
});
}, { rootMargin: '200px 0px' }); // start loading 200px before viewport
mapContainers.forEach((el) => observer.observe(el));
}
// 2. Slider/carousel: initialize only when the slider enters the viewport
function initLazySliders() {
const sliders = document.querySelectorAll('[data-lazy-slider]');
if (!sliders.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
observer.unobserve(entry.target);
// Dynamically import the slider module
import('/wp-content/themes/mytheme/js/slider.js').then(({ initSlider }) => {
initSlider(entry.target);
});
});
}, { rootMargin: '100px 0px' });
sliders.forEach((el) => observer.observe(el));
}
// Run after DOM ready
document.addEventListener('DOMContentLoaded', () => {
initLazyMap();
initLazySliders();
});
Quick checklist
- Chrome DevTools Performance trace shows no long tasks (over 50ms) from third-party origins during initial page load
-
Total DOM node count under 1,500 (check with
document.querySelectorAll('*').lengthin DevTools Console) -
All theme scripts use
wp_enqueue_scriptwithstrategy => 'defer'andin_footer => true -
Event listeners use targeted delegation (not
$(document).on) and scroll listeners use{ passive: true } -
Maps, sliders, and other heavy widgets initialize via
IntersectionObserverwhen they enter the viewport -
Real-user INP measured via
web-vitalslibrary'sonINP()and reported to analytics for field validation
Frequently asked questions
High INP in WordPress is most commonly caused by long tasks on the main thread. The primary sources are third-party scripts from ad networks, chat widgets, and analytics running long JavaScript tasks during page load; jQuery plugins registering hundreds of event listeners on the document root; page builders generating 2,000+ DOM nodes that make every style recalculation slow after each click or keystroke; and large synchronous JavaScript files that execute before the browser is ready to accept user input.
jQuery itself (30KB gzipped) performs comparably to vanilla JavaScript for most operations on modern browsers. The INP problems attributed to jQuery come from how it is used: event delegation attached to the document root runs on every interaction, jQuery animations that modify the DOM on every frame, and plugins using $('*') or other broad selectors traversing the full DOM. Replacing these specific patterns with modern equivalents reduces INP; removing jQuery entirely is rarely necessary if the problematic patterns are fixed.
Yes. Elementor, Divi, and WPBakery inflate DOM node counts to 2,000-5,000 nodes per page. Every click triggers a style recalculation that becomes slower as the DOM grows. Elementor Pro also initializes editor infrastructure JavaScript on the front-end, adding unnecessary event listeners for visitors. Page builders also load JavaScript for every registered widget type regardless of which widgets appear on the current page, increasing total script parse and execution time.
For lab measurement, open Chrome DevTools Performance panel and record a trace while clicking buttons, opening menus, or typing in search fields. Look for long tasks (red-flagged blocks over 50ms) in the Main thread track. For field data, add the web-vitals JavaScript library via wp_enqueue_script and call onINP() to capture real user interaction latency. Google Search Console's Core Web Vitals report shows aggregated INP data from Chrome users after 28 days of collection.
WP Rocket improves INP by delaying execution of specified JavaScript files until after the first user interaction, removing them from the critical interaction path. Perfmatters provides granular control over which scripts load on which page types, reducing unnecessary JavaScript on pages where plugins are not needed. Asset CleanUp similarly allows disabling specific stylesheets and scripts on a per-page basis. For Elementor-heavy sites, the Elementor Optimizer third-party tool can reduce its front-end JavaScript footprint significantly.
Use Chrome DevTools Performance panel with CPU throttling (4x slowdown) to simulate mid-range mobile devices. Interact with the page (click buttons, type in inputs, open menus) and look for long tasks in the flame chart. The Web Vitals Chrome Extension shows real-time INP scores as you interact. For Wordpress, pay attention to hydration-related interaction delays.
Related resources
Complete INP Guide
Deep dive into INP measurement, interaction phases, long task attribution, and optimization strategies for any stack.
FixFix INP in Next.js
Next.js INP fixes using React Server Components, Suspense boundaries, useTransition, and dynamic imports for interaction-heavy apps.
FixFix INP in React
React-specific INP fixes including useDeferredValue, startTransition, code splitting, and virtualization for long lists.
Continue learning
Complete INP Guide
Deep dive into INP -- thresholds, measurement, and optimization strategies.
FixFix LCP in WordPress
Related performance optimization for the same framework.
FixFix CLS in WordPress
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.