Improve Core Web Vitals (CLS, LCP, INP) in WordPress

October 27, 2025
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-vitals JS 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-change sparingly; 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 preconnect for 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.

Avatar

Written by

satoshi

I’ve been building and customizing WordPress themes for over 10 years. In my free time, you’ll probably find me enjoying a good football match.