100/100 Core Web Vitals for WordPress
EN

100/100 Core Web Vitals for WordPress

Last verified: August 24, 2026
15 min read
Guide
Core Web Vitals
Full-stack developer

100/100 in Lighthouse is a lab result. The Core Web Vitals Google uses for ranking are p75 in the Chrome UX Report over 28 days. Mix the two and you chase a screenshot while the field is red.

WordPress 7.1 on PHP 8.4 can deliver green vitals. It does not do it by itself. A theme with Elementor, a consent banner that shoves the hero, and a checkout widget that hydrates 200 KB on click, lost INP before LCP had finished. This guide has two layers: field (CrUX) that must sit in Google’s green bands, and lab (Lighthouse mobile) where we internally hunt 100/100 because it forces discipline in images, CSS and third parties.

Google’s published “good” for LCP is 2.5 seconds at p75 in the field. That has not changed in this text, and we do not pretend a 2026 document lowered the official list. When we say the hero should paint nearer one second, that is our internal lab budget, not Google’s threshold. INP “good” is 200 ms. CLS “good” is 0.1. Lab 100/100 wants tighter numbers than that, because Lighthouse punishes TTFB, unused CSS and main-thread work in a simulated run.


#Field versus lab: CrUX is not Lighthouse

CrUX is field data from Chrome users who opted into telemetry. The window is 28 days. The metric that counts is the 75th percentile for the URL or the origin. Lighthouse is lab: one device, one network profile, one load, often without login, without the payment widget, without the consent banner that only shows in the EEA.

Consequences:

  • A page can have LCP 1.1 s in lab and 2.8 s in the field because a share of hits arrive on 4G at the edge of coverage, or because origin is hit without HTML cache.
  • A page can have 100 in lab and red INP in the field because a Klarna or Stripe widget is injected only for shop visitors.
  • PageSpeed Insights shows both. Read the field tab first. Lab is diagnosis, not the verdict.

Your own RUM with web-vitals (onLCP, onINP, onCLS) sent via sendBeacon gives you p75 before CrUX has rolled 28 days. That is internal steering. It does not replace CrUX in Search Console. Never report a Lighthouse number as if it were CrUX.

MeasurementSourceWhat it isWhat it is not
LCP p75 fieldCrUXGoogle’s ranking signalA Lighthouse run
INP p75 fieldCrUXAll interactions, 200 ms greenFirst click (that was FID)
CLS p75 fieldCrUXUnexpected layout shift, 0.1 green”It looked stable on a Mac”
100/100 mobileLighthouseLab under a fixed profileField, and not a Google threshold

#LCP: Google’s 2.5 s and our 1.2 s lab budget

Largest Contentful Paint is the time until the largest content element in the viewport is painted. On WordPress it is almost always the hero image, sometimes an H1 over a background image that never becomes LCP because the text wins.

Google, field, p75: 2.5 s or better is green. Between 2.5 and 4.0 s is “needs improvement”. Over 4.0 s is red. That is the list. Internally, in Lighthouse mobile, we often hunt LCP under 1.2 s because 100/100 requires it, and because it leaves margin when the field is harsher than lab. That is our target. It is not Google’s new threshold.

LCP time is TTFB plus load delay plus download plus render delay. Typical WordPress failures:

  1. Hero with loading="lazy". Core and many themes lazy-load everything. Lazy on the LCP image delays the start of the download until after layout. Remove lazy on the first image. Set fetchpriority="high". Preload in <head> with imagesrcset and imagesizes that match what is actually shown.

  2. WebP where AVIF is cheaper in bytes. AVIF is the format we encode to in 2026. WebP is fallback. JPEG is the original in the media library, not the delivery. A hero at 1800 px does not need a 3 MB PNG from the last campaign export.

  3. A font that blocks paint. If LCP is text, the webfont is on the critical path. Subset to the scripts you actually use, font-display: optional or a metric-adjusted fallback.

  4. Origin without cache. Preload does not help if HTML arrives after 700 ms.

Two cases we measured:

  • A WooCommerce catalogue with 30-plus plugins and TTFB around 1.8 s on category templates. Related products, reviews, schema and a mega-menu assembled in PHP on every request. HTML cache for anonymous category URLs, AVIF heroes, no lazy on the first image. Field LCP moved. Checkout stayed uncached.
  • An Elementor landing that collapsed under campaign traffic. The builder painted every variant in PHP. There was no cache-tag story, only “purge all”. We did not decouple that site. We stripped the builder, kept a coupled theme, Redis and page cache. Decoupling a page-builder stack is an editorial rewrite, not a hosting change.
<?php
declare(strict_types=1);

add_action('wp_head', static function (): void {
    if (!is_front_page() && !is_singular()) {
        return;
    }
    $url = get_the_post_thumbnail_url(null, 'full');
    if (!$url) {
        return;
    }
    printf(
        '<link rel="preload" as="image" href="%s" fetchpriority="high" />' . "\n",
        esc_url($url)
    );
}, 1);

On Astro 6 and Next.js 16, preload lives in the template, not in wp_head. Origin delivers URL and dimensions via GraphQL. The frontend writes <link rel="preload"> and <img> with width, height and an AVIF srcset. Do not let WordPress and the framework both guess.


#HTMLRewriter at the edge versus a CSS-inliner plugin

This is the international operating choice we keep repeating: critical CSS and preload hints belong at the HTML boundary, not in another mu-plugin that runs on every PHP hit.

A plugin that “inlines critical CSS” still needs PHP to start, query the post, and print a <style> block. That helps render delay after TTFB. It does nothing for TTFB. If origin is 600 ms, you have already spent the LCP budget before the inliner runs.

Cloudflare HTMLRewriter (or the equivalent HTML transform at the edge) rewrites a cached document: inject preload for the LCP image, drop a tracker that marketing added in wp_footer, add fetchpriority if the template forgot it. The PHP origin is not in that path. Anonymous GET stays a cache hit.

Rules we actually use:

  1. Anonymous HTML is cached with tags (post-id, template-home, lang-en). Publish purges tags, not the zone.
  2. HTMLRewriter only mutates safe, idempotent hints. It does not personalise. Personalised HTML is a miss by construction.
  3. A CSS-inliner plugin is allowed on a coupled theme while you migrate, then it comes out. Two inliners (plugin plus edge) duplicate bytes and fight over which <style> wins.

LiteSpeed Cache plus Cloudflare “cache everything” plus an inliner is three caches and no shared invalidation. The editor saves, origin updates, the edge serves yesterday’s homepage for two hours. That is not a Core Web Vital strategy. It is a support queue.


#INP: 200 ms and the checkout widget

Interaction to Next Paint replaced FID in 2024. FID measured the first click. INP measures click, tap and key through the whole session. Green INP in the field is 200 ms or better at p75. Between 200 and 500 ms is “needs improvement”. Over 500 ms is red. Lab 100/100 often wants well under 200 ms because Lighthouse punishes long tasks hard.

INP is input delay plus processing plus presentation. On WordPress shops, processing is almost always a third party, not your toggleMenu().

Payment and messaging widgets (Stripe, PayPal, Klarna on-site messaging, Apple Pay buttons) load external script, open a modal, and register handlers on buttons in the hero or the cart. If the script loads synchronously in <head>, it owns the main thread before the first click on the menu. Load the widget when checkout is in the viewport (client:visible in Astro 6, dynamic import in Next.js 16), or on idle. The button can be HTML with href to a thin checkout route. Hydrate the widget there, not on the homepage.

Other:

  • Google Tag Manager, Meta Pixel, Hotjar, chat: Partytown or load after requestIdleCallback. Never on the critical path. Partytown is not free. It moves work to a worker. It does not make a 400 KB tag container small.
  • Long lists (shop filters, mega-menu): scheduler.yield() between chunks, or virtualise. A querySelectorAll over 2000 nodes in a click handler is an INP breach.
  • Mobile menu: the first tap on the hamburger is often the INP CrUX captures. If the click waits for the whole nav tree to hydrate, 200 ms is gone. A server-rendered <details> menu, or an island that owns only the menu, is enough. Do not hydrate the entire header as one React island.
async function yieldToMain() {
  if ('scheduler' in window && 'yield' in window.scheduler) {
    return window.scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Measure INP in the field per template, not as an origin average. A blog can be green while checkout is red. Origin-level CrUX hides that. URL-level Search Console and your own RUM per page_type show it.


Cumulative Layout Shift sums unexpected jumps. Green CLS in the field is 0.1 or better. Lab 100/100 wants near 0.00. The two sources that beat missing width and height on images are consent and theme.

Consent banner (ePrivacy + GDPR). Cookiebot, OneTrust, Usercentrics and home-grown bars are often injected after hydration. The banner takes 80 to 120 px at the top or bottom. The hero, already LCP, is shoved. That is CLS, and it hits almost every first-time visit in the EEA. CrUX p75 records it. Lighthouse without the banner does not.

Fix: reserve the space in CSS before the script runs. Fixed min-height on a wrapper that is always in the template. Do not animate height from 0 to 96 px. If the banner is an overlay, reserve nothing in flow, but lock scroll without changing padding-top on <body> after measuring. Load the consent script so it does not win over LCP, but leave the wrapper in the first HTML.

Late dark mode. If the theme class (dark on <html> or <body>) is set in a client script after first paint, background, text and shadows jump. That is CLS and often a flash. Fix: a tiny synchronous <script> in <head> before CSS, read localStorage and matchMedia, and keep colour variables so the swap does not change geometry. Swap colour, not font-size and line-height. filter: invert on body is a CLS and contrast problem, not a theme.

Other CLS:

  • Images and iframes: width, height or aspect-ratio. YouTube embed in a 16/9 box.
  • Fonts: size-adjust, ascent-override on the fallback so Arial and Geist take the same line box.
  • Ads and related blocks: min-height, not “we do not know until the API answers”.
.consent-slot {
  min-height: 96px;
}

.hero-media {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

@font-face {
  font-family: Geist-Fallback;
  src: local("Arial");
  size-adjust: 106%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

#Images and fonts without stealing LCP

The media library stores originals. Delivery is AVIF at 400, 768, 1200 and 1920 widths, with sizes that match the layout, not 100vw on an article in a 680 px column. loading="lazy" on everything below the fold. decoding="async". No carousel in the hero that loads five LCP candidates.

Fonts: two weights, not five. Subset. WOFF2. Self-host, not a Google Fonts CSS that adds two extra rounds before @font-face. On Astro 6 and Next.js 16 this lives in the build pipeline. On a monolith: a plugin or build that writes subset files, not “Elementor Custom Fonts” with TTF.

Critical CSS belongs in the first packet. Header, type and hero box get style in the HTML, under about 14 KB compressed, so first paint does not wait on style.css. The rest of the sheet loads async. Elementor and Divi builds that send 400 KB CSS to every URL lose LCP before the image is mentioned. In headless, Astro and Next emit only the class names the page uses. In a monolith: extract above-the-fold per template, or change theme.

content-visibility: auto on comments, related grid and footer cuts style and layout work during first paint. Set contain-intrinsic-size so the scrollbar does not jump (that is CLS).


#Astro 6 islands and Next.js 16 for vitals

Two fronts, same requirement: first paint without unnecessary JavaScript.

Astro 6 is the default for content. HTML and CSS out. Islands where there is a click. client:idle for search. client:visible for a cart widget. An article with no islands sends 0 KB JS and wins lab. That is why 100/100 is reachable on a magazine without cheating the design.

Next.js 16 is right when the page is an app. PPR: static shell, dynamic holes. React Server Components for the list, a client component for the filter. The whole App Router as "use client" on the layout is giving INP away. Partial Prerendering helps LCP. It does not help if useEffect in the root layout loads Tag Manager and the payment widget at once.

A monolith on PHP 8.4 with a tight theme, Redis and Cloudflare can also go green in the field. It loses when the editor installs “one small” popup plugin. Headless loses when the island imports the whole icon pack. Architecture is chosen in the headless architecture guide. Vitals are the outcome of that architecture, plus third parties.

Speculation Rules (<script type="speculationrules"> with conservative prerender on internal links) cut the next navigation toward zero in supported Chrome clients. That improves perceived speed. It does not change LCP on a landing from Google, which is the URL CrUX cares about.


#PHP 8.4, Redis and TTFB behind Cloudflare

Uncached HTML on shared hosting is the LCP ceiling. Redis object cache cuts SQL for logged-in users and for cache misses. Opcache on 8.4 cuts PHP startup. A page cache in front (Cloudflare, or Nginx fastcgi_cache if you own the VPS) cuts PHP entirely for anonymous hits.

Priority:

  1. HTML cache for anonymous, with purge on publish.
  2. Redis for WP_Query and transients.
  3. Fewer plugins on the front. Every wp_enqueue_script is INP.
  4. Database indexes and autoload cleanup. Autoload of 2 MB in wp_options is TTFB before the theme has started.

WordPress 7.1 does not change this. Core is not the LCP problem. The script queue and the distance to origin are.


#RUM, budgets and regression

Lab in CI: Lighthouse budgets on PRs for the templates that matter (home, article, product, checkout). Fail the build if JS on the article template exceeds a few tens of kilobytes, or if the LCP element lacks dimensions. That catches regression from a new plugin or a new island.

Field: web-vitals to your endpoint, aggregated p75 per template per week. Alarm when INP crosses 200 ms in the field, not when someone ran Lighthouse on fibre. CrUX API weekly as the source of record against Search Console.

Budgets that survive a campaign week:

  • Consent wrapper has height in CSS.
  • Theme script in <head> is under 1 KB and does not touch layout.
  • Payment widgets are not on the homepage.
  • Hero is AVIF, preload, not lazy.

Without a budget, 100/100 is a screenshot from launch week.

Multisite does not change the thresholds. It changes origin load. One Redis instance per network, HTML cache per blog ID, and no shared all.min.js that loads WooCommerce scripts on the magazine site. With that, Multisite can sit as green in CrUX as a single install. Without it, every site inherits the slow neighbour’s enqueue.


#Audit and further reading

The Core Web Vitals audit is field plus lab plus a list of third parties that must leave the main thread. We open CrUX and Search Console first, Lighthouse afterwards, and we measure checkout as its own template.

Speed work on an existing monolith is in how to speed up a WordPress site. Decoupling as topology, not as a performance spell, is in headless architecture.


#Conclusion

Google has not lowered the LCP list to 1.2 s. Green LCP in the field is 2.5 s. Green INP is 200 ms. Green CLS is 0.1. 100/100 is lab discipline. On WordPress that means HTML cache in front of origin, space for the consent banner, a theme without FOUC, checkout widgets off the first interaction, and an edge rewrite for hints instead of another PHP inliner. That is what separates green field from a pretty Lighthouse screenshot.

Write with the URL and Search Console access if the field is red and the lab is lying. The speed optimisation service is the commercial surface.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Is 100/100 in PageSpeed Insights required for SEO in 2026?#
No. Google ranks on CrUX field data (p75), not on the lab score. 100/100 is an internal discipline target. Green LCP in the field is 2.5 s.
What is the difference between Lighthouse and CrUX?#
Lighthouse is lab: a controlled device, often simulated 4G, one URL, one run. CrUX is field: real Chrome users over 28 days. You can have 100 in lab and red INP in the field if a checkout widget only loads for shop visitors.
What usually breaks INP on WordPress shops?#
Third parties on the main thread: consent platform, Tag Manager, payment widgets, chat. They must leave the first interaction.
Does managed WordPress hosting alone fix LCP?#
Rarely. Shared PHP workers and file object cache swing TTFB. HTML cache in front of origin, with wp-admin bypass, is what moves LCP in the field.
Why does a consent banner pass Lighthouse and fail CrUX?#
Lab often skips the EEA banner. In the field the first visit gets a bar after hydration and the hero jumps. Reserve min-height in the first HTML before the CMP script runs.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles