Most WordPress sites fail Core Web Vitals for the same few reasons: a slow server response, a heavy hero image, too much JavaScript and layout shifts from fonts and ads. This checklist works through each one in the order I tackle them, with the code and settings to fix it. If you work through it, you have a realistic path to passing WordPress Core Web Vitals on real-user data, not just on a lab score.
No plugin fixes everything in one click. A caching plugin is a good start, but your theme, your plugins and your third-party scripts decide the result.
Know the targets: LCP, INP and CLS thresholds
Google measures three metrics, each at the 75th percentile of real visits over a rolling 28-day window:
- Largest Contentful Paint (LCP): when the main content, usually the hero image or heading, has rendered. Good is 2.5 seconds or less.
- Interaction to Next Paint (INP): how quickly the page responds visually to taps, clicks and key presses. Good is 200 milliseconds or less. INP replaced First Input Delay in March 2024.
- Cumulative Layout Shift (CLS): how much content jumps around while the page loads. Good is 0.1 or less.
Because the 75th percentile is what counts, your slower visitors on mid-range Android phones matter more than your own laptop on office Wi-Fi.
Measure first with PageSpeed Insights, CrUX and Search Console
Before changing anything, find out where you stand and which templates are failing.
- PageSpeed Insights shows two different things. The top section is field data from the Chrome UX Report (CrUX), which is what Google uses. The Lighthouse section below it is a lab test on a simulated device. Lab results help you debug. Field data decides whether you pass.
- Search Console’s Core Web Vitals report groups similar URLs, so you can see whether the issue is on blog posts, product pages or the home page.
- The CrUX API or CrUX dashboard gives you history, so you can confirm a fix actually moved the numbers. Expect changes to take up to four weeks to show fully because of the 28-day window.
Low-traffic sites may have no URL-level CrUX data at all. In that case, collect your own field data with Google’s web-vitals library:
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
page: location.pathname,
});
navigator.sendBeacon('/wp-json/vitals/v1/collect', body);
}
onLCP(send);
onINP(send);
onCLS(send);
The attribution build also tells you which element was the LCP and which interaction was slow, which is exactly what you need to debug. You will need to register the collection endpoint yourself, or send the data to an analytics tool you already use.
Hosting, PHP version and server response time
LCP cannot be faster than your Time to First Byte. If the HTML takes 1.5 seconds to arrive, you have one second left for everything else.
- Run a currently supported PHP 8 version. Each 8.x release has brought performance work, and outdated versions also miss security fixes.
- Confirm OPcache is enabled. Without it, PHP recompiles WordPress on every request.
- Add a persistent object cache (Redis or Memcached) for logged-in users, WooCommerce and admin. Page caching does not help those requests.
- Check the Site Health screen and Query Monitor for slow database queries, heavy
autoloadoptions and plugins that make remote HTTP calls on every page load.
Page caching plus CDN edge caching (Cloudflare cache rules)
Page caching is the biggest single win for TTFB. A plugin-level cache (WP Super Cache, W3 Total Cache, LiteSpeed Cache or your host’s built-in cache) serves static HTML from the server. Edge caching goes one step further and serves that HTML from a CDN location near the visitor.
By default, Cloudflare caches static files but not HTML. You can tell it to cache HTML with a Cache Rule, as long as you bypass the cache for anyone who should see personalised content. In Cloudflare’s dashboard, go to Caching → Cache Rules, create a rule with “Eligible for cache” and an Edge TTL, and use a custom expression like this:
(http.host eq "example.com"
and not starts_with(http.request.uri.path, "/wp-admin")
and http.request.uri.path ne "/wp-login.php"
and not starts_with(http.request.uri.path, "/wp-json")
and not http.cookie contains "wordpress_logged_in_"
and not http.cookie contains "wp-postpass_"
and not http.cookie contains "comment_author_"
and not http.cookie contains "woocommerce_items_in_cart")
On WooCommerce sites, also exclude the cart, checkout and my-account paths. Then make sure the cache is purged when content changes, either with Cloudflare’s official WordPress plugin, your caching plugin’s Cloudflare integration, or Cloudflare APO, which handles HTML caching and purging for WordPress as a paid add-on.
Test the rule in a private window: the cf-cache-status response header should show HIT on the second request, and a logged-in session should always show BYPASS or DYNAMIC.
Images: sizes, srcset and fetchpriority for a faster LCP
On most WordPress pages the LCP element is an image. Get these right:
- Always output images through WordPress functions such as
wp_get_attachment_image()orthe_post_thumbnail(). They addwidth,height,srcsetandsizesautomatically, which avoids layout shift and lets phones download a smaller file. - Register image sizes that match your layout instead of serving the full upload and shrinking it with CSS.
- Never lazy-load the LCP image. Since WordPress 6.3, core tries to detect the likely LCP image, skips
loading="lazy"on it and addsfetchpriority="high". Custom themes and sliders often defeat this detection, so set it explicitly for your hero:
echo wp_get_attachment_image(
get_post_thumbnail_id(),
'hero-large',
false,
[
'loading' => 'eager',
'fetchpriority' => 'high',
'sizes' => '100vw',
]
);
If the hero is a CSS background image, the browser finds it late. Either switch it to an <img> element or preload it with <link rel="preload" as="image" fetchpriority="high">. Serve WebP or AVIF where your setup supports it, and compress uploads. A 400 KB hero photo is rarely necessary.
JavaScript: defer scripts, drop jQuery and tame third parties for INP
Poor INP is almost always caused by too much JavaScript running on the main thread. Your own scripts are the easiest place to start. Since WordPress 6.3 you can register scripts with a loading strategy:
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script(
'theme-main',
get_theme_file_uri( 'assets/js/main.js' ),
[], // no jQuery dependency
'1.4.0',
[
'strategy' => 'defer',
'in_footer' => true,
]
);
} );
Remove jQuery where you can
jQuery is not slow by itself, but it is often loaded only because one small script asks for it. Rewrite your own theme scripts in plain JavaScript and remove 'jquery' from their dependencies. If no remaining script depends on it, WordPress won’t load it on the front end. I don’t recommend force-deregistering jQuery, because plugins that expect it will break silently.
Third-party scripts
Chat widgets, heatmaps, tag managers and embedded videos are the usual INP problems. For each one, ask whether it is needed on every page. Load it only where it is used, or delay it until the first interaction. Swap YouTube iframes for a lightweight facade that loads the player on click.
Large page-builder DOMs also hurt INP, because every style recalculation has more work to do. That is one reason I build structured sites with a custom theme, custom post types and ACF instead of a page builder.
Dequeue plugin assets on pages that don’t use them
Many plugins load their CSS and JavaScript on every page, even if their feature appears on only one. A contact form plugin is the classic example. Find the exact handles with Query Monitor’s “Scripts” and “Styles” panels, then remove them conditionally:
add_action( 'wp_enqueue_scripts', function () {
if ( is_page( 'contact' ) ) {
return;
}
// Handles vary by plugin and version: confirm them in Query Monitor.
wp_dequeue_script( 'contact-form-7' );
wp_dequeue_style( 'contact-form-7' );
}, 100 );
The late priority (100) makes sure this runs after the plugin enqueues its assets. Some smaller wins in the same spirit:
// Remove the emoji detection script and styles (WordPress 6.4+).
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' );
// Classic themes only: skip block library CSS where no blocks are used (test carefully).
add_action( 'wp_enqueue_scripts', function () {
if ( ! is_singular() || ! has_blocks() ) {
wp_dequeue_style( 'wp-block-library' );
}
}, 100 );
Fonts: self-host, preload and stop layout shifts
Web fonts affect both LCP (text can’t paint until the font arrives) and CLS (text reflows when the font swaps in).
- Self-host fonts as WOFF2 instead of loading them from a third-party domain. This removes an extra connection and gives you control over caching.
- Load fewer files. Two weights of one family is usually enough. A variable font can replace several static files.
- Preload only the font used above the fold, for example the heading weight.
- Use
font-display: swapand tune the fallback so the swap barely shifts the layout:
@font-face {
font-family: "Inter";
src: url("/wp-content/themes/mytheme/fonts/inter-var.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
/* Fallback metrics adjusted to match Inter more closely */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}
The right size-adjust value depends on the font pair, so test it visually. Other common CLS causes are ads, cookie banners and embeds without reserved space. Give those containers a fixed min-height or aspect-ratio.
WordPress Core Web Vitals checklist
- Check field data in PageSpeed Insights and Search Console, and note which templates fail.
- Upgrade to a supported PHP 8 version with OPcache, and add an object cache.
- Enable page caching, then edge caching with cookie-based bypass rules.
- Make sure the LCP image is eager-loaded with
fetchpriority="high"and correctly sized. - Defer theme scripts and remove unneeded jQuery dependencies.
- Load or delay third-party scripts only where they are needed.
- Dequeue plugin CSS and JS on pages that don’t use them.
- Self-host and preload critical fonts, and reserve space for ads and embeds.
- Re-check CrUX after 28 days to confirm the improvement.
Performance and security often touch the same server and CDN settings, so it is worth pairing this with my WordPress security hardening checklist.
Frequently asked questions
Why does PageSpeed Insights show a good score but fail Core Web Vitals?
The performance score comes from a single lab test, while the Core Web Vitals assessment uses real-user field data from CrUX. Real visitors on slower devices and networks often have a worse experience than the lab simulation.
Which caching plugin is best for WordPress Core Web Vitals?
There is no single best plugin. The right choice depends on your server: LiteSpeed Cache suits LiteSpeed hosting, and many managed hosts include their own page cache. What matters most is that HTML is cached and that logged-in and cart users bypass it.
Can I pass Core Web Vitals with a page builder?
Yes, but it usually takes more work because page builders add extra CSS, JavaScript and DOM elements. Disabling unused widgets and modules, and using the builder’s own performance settings, helps a lot.
How long does it take for Core Web Vitals improvements to show?
CrUX uses a rolling 28-day window, so fixes appear gradually and take up to about four weeks to be fully reflected. Search Console may take a little longer to validate a fix.
If your WordPress site is failing Core Web Vitals and you want someone to find and fix the real causes, contact me with the URL and I’ll tell you where I would start.