Improve Core Web Vitals (CLS, LCP, INP) in WordPress
Core Web Vitals measure real-world UX: LCP (loading), CLS (visual stability), and INP (interactivity). This guide shows practical, copy-paste improvements you can make in WordPress — focusing on images, fonts, scripts, and templates — to raise scores reliably.
Before You Start: How to Measure
- Field data: Chrome UX Report, Search Console (Core Web Vitals report).
- Lab data: PageSpeed Insights, Lighthouse, WebPageTest.
- In-house: Add
web-vitalsJS to send metrics to your analytics.
// minimal: collect INP/LCP/CLS in production
import { onCLS, onINP, onLCP } from 'web-vitals';
onCLS(console.log); onINP(console.log); onLCP(console.log);
Largest Contentful Paint (LCP): <2.5s (75th percentile)
1) Serve the LCP image fast (fetchpriority + no lazy)
For the hero/featured image, don’t lazy-load and hint priority.
<?php
// Mark the first featured image as high priority and not lazy
add_filter( 'wp_get_attachment_image_attributes', function ( $attr, $attachment, $size ) {
static $lcp_done = false;
if ( $lcp_done ) return $attr;
if ( is_front_page() || is_home() || is_singular() ) {
$attr['fetchpriority'] = 'high';
$attr['loading'] = 'eager';
// Optional: decoding async often helps painting earlier
$attr['decoding'] = 'async';
$lcp_done = true;
}
return $attr;
}, 10, 3 );
2) Output a tight, responsive srcset/sizes
Send only sizes you’ll actually use; avoid bloated candidate lists.
<?php
if ( has_post_thumbnail() ) {
$id = get_post_thumbnail_id();
$src1x = wp_get_attachment_image_url( $id, 'large' ); // ~1024w
$src2x = wp_get_attachment_image_url( $id, 'full' ); // fallback bigger
echo wp_get_attachment_image( $id, 'large', false, [
'srcset' => esc_attr( "$src1x 1024w, $src2x 1920w" ),
'sizes' => '(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px',
] );
}
3) Preload the exact LCP resource
Preload the same URL WordPress will render (avoid double-download). Do this early in wp_head.
<?php
add_action( 'wp_head', function () {
if ( ! is_singular() && ! is_front_page() ) return;
if ( ! has_post_thumbnail() ) return;
$id = get_post_thumbnail_id();
$src = wp_get_attachment_image_url( $id, 'large' );
if ( $src ) {
printf( '<link rel="preload" as="image" href="%s" imagesrcset="%s" imagesizes="%s">',
esc_url( $src ),
esc_attr( wp_get_attachment_image_srcset( $id, 'large' ) ),
esc_attr( '(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px' )
);
}
}, 1 );
4) Defer non-critical JS & inline critical CSS
Move heavy JS off the critical path and inline a tiny “above-the-fold” CSS.
<?php
// WP 6.3+: native strategy support
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script( 'theme', get_stylesheet_directory_uri() . '/assets/js/theme.js', [], '1.0', [
'in_footer' => true,
'strategy' => 'defer',
] );
} );
<?php
// Inline a small critical CSS block (keep <3 KB)
add_action( 'wp_head', function () {
?><style>header{min-height:56px} .hero{display:grid;place-items:center;min-height:60vh} /* ... */</style><?php
}, 20 );
Cumulative Layout Shift (CLS): <0.1
1) Always reserve space (width/height or aspect-ratio)
<?php
// Ensure <img> has width/height attributes
add_filter( 'wp_get_attachment_image_attributes', function ( $attr, $attachment ) {
if ( empty( $attr['width'] ) || empty( $attr['height'] ) ) {
$meta = wp_get_attachment_metadata( $attachment->ID );
if ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) {
$attr['width'] = (int) $meta['width'];
$attr['height'] = (int) $meta['height'];
}
}
return $attr;
}, 10, 2 );
/* Fallback space reservation when markup lacks intrinsic size */
.img-ratio {
display:block;
aspect-ratio: 4 / 3; /* match your thumbnails */
width:100%;
height:auto;
}
2) Stabilize dynamic UI (menus, alerts, injected banners)
/* Reserve height for fixed header / admin bar shifts */
.site-header { min-height: 64px; }
.notice-area { min-height: 0; transition: max-height .25s ease; }
// Prevent late DOM injections from pushing content
const reserve = document.querySelector('.notice-area');
function showNotice(html){
reserve.style.maxHeight = '0px';
reserve.innerHTML = html;
requestAnimationFrame(() => { reserve.style.maxHeight = reserve.scrollHeight + 'px'; });
}
3) Fonts without FOIT/FOUT jumps
- Self-host fonts and include
font-display: swap. - Preload the exact WOFF2 used above the fold.
@font-face{
font-family:'Inter';
src:url('/wp-content/themes/child/assets/fonts/inter-regular.woff2') format('woff2');
font-weight:400; font-style:normal; font-display:swap;
}
:root { --fw: 400; --fs: clamp(16px, 1.6vw, 18px); }
body { font: var(--fw) var(--fs)/1.6 'Inter', system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
4) Ads/embeds/iframes
Reserve fixed slots or ratios for ad units and embeds.
<div class="ad-slot" style="width:300px;height:250px;"><!-- ad fills this box --></div>
Interaction to Next Paint (INP): <200 ms
1) Defer heavy JS, split long tasks
// Break up long tasks (>50ms) during boot
import('./charts.js').then(initCharts); // code-split
requestIdleCallback(() => warmUpNonCritical());
// Split a heavy loop:
const items = bigList();
function chunkedProcess(i=0){
const t0 = performance.now();
while(i < items.length && performance.now()-t0 < 16){
process(items[i++]);
}
if(i < items.length) requestAnimationFrame(() => chunkedProcess(i));
}
chunkedProcess();
2) Keep event handlers lightweight
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if(!btn) return;
// 1) Provide immediate UI feedback
btn.setAttribute('aria-busy','true');
// 2) Defer heavy work
setTimeout(() => {
doWork(btn.dataset.action); // heavy task
btn.removeAttribute('aria-busy');
}, 0);
}, { passive: true });
3) Smooth scrolling without jQuery .animate()
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', (e) => {
const id = a.getAttribute('href').slice(1);
const el = document.getElementById(id);
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, { passive: true });
});
4) Reduce main-thread pressure
- Remove unused libraries; prefer native features over jQuery for new code.
- Use
will-changesparingly; avoid forced reflows in loops. - Delegate events on containers instead of attaching many listeners.
Theme/Template Tweaks That Pay Off
1) Only load what the page needs
<?php
add_action( 'wp_enqueue_scripts', function () {
if ( ! is_singular('post') ) {
wp_dequeue_style( 'wp-block-library' ); // example if not needed
}
}, 20 );
2) Defer non-critical embeds and iframes
<iframe src="about:blank" data-src="https://www.youtube.com/embed/ID"
width="560" height="315" loading="lazy" title="Video" allowfullscreen></iframe>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('iframe[data-src]').forEach(f => f.src = f.dataset.src);
});
</script>
3) Use decoding="async" on non-critical images
<?php
add_filter( 'wp_get_attachment_image_attributes', function( $a ) {
if ( empty( $a['loading'] ) ) $a['loading'] = 'lazy';
$a['decoding'] = $a['loading'] === 'lazy' ? 'async' : 'auto';
return $a;
}, 10, 1 );
Server & Network
- TTFB: enable full-page caching (page cache plugin or reverse proxy), upgrade PHP to a recent version, and use persistent object cache (Redis/Memcached).
- Compression: enable Brotli/gzip; serve images as WebP/AVIF where possible.
- CDN: offload static assets; add
preconnectfor critical third-party domains.
<?php
add_action( 'wp_head', function () {
echo '<link rel="preconnect" href="https://cdn.example.com" crossorigin>';
}, 1 );
Quick Checklists
LCP
- ✅ No lazy on the LCP image; add
fetchpriority="high". - ✅ Preload the exact LCP resource with matching
imagesrcset/imagesizes. - ✅ Defer non-critical JS; inline tiny critical CSS.
CLS
- ✅ All images/iframes have width/height or CSS
aspect-ratio. - ✅ Reserve slots for ads/embeds; avoid late DOM injections.
- ✅ Self-hosted fonts +
font-display: swap+ preload.
INP
- ✅ Break long tasks; code-split heavy modules.
- ✅ Lightweight event handlers; provide instant visual feedback.
- ✅ Avoid synchronous layout thrash; use
requestAnimationFrame/idle.
Putting It Together
Start with the LCP image (priority + preload), then eliminate CLS by reserving space for every visual element. Finally, trim/main-thread JS to drive INP under 200 ms. Apply these snippets in your theme or child theme, re-measure with PageSpeed Insights, and iterate until field data in Search Console turns green.
🎨 Want to learn more? Visit our WordPress Customization Hub for tips and advanced techniques.