All articles

Performance

From 92 to 99 on mobile: Core Web Vitals on an Elementor site

By Rolando Escobar, WordPress developer for agencies · · 5 min read

The first mobile Lighthouse run after I rebuilt rolandowp.com gave the home page a Performance score of 92, with a Largest Contentful Paint of 2.8 seconds. LCP was the obvious problem, so the job was narrow: find what the LCP element was waiting for and take it out of the way.

After the changes below, the same page scores 99 on mobile: LCP 1.9 s, Total Blocking Time 0 ms and Cumulative Layout Shift 0, with 100 in Accessibility, Best Practices and SEO. The stack is WordPress 7.1 with Elementor and Elementor Pro 4.3, built with V4 atomic elements on a Hello Elementor child theme. I’ve done Core Web Vitals work on US client sites before; this site is my own, so I can show the numbers.

Measure the same way every time

Lab scores move between runs, and a single run can send you chasing noise. Run Lighthouse a few times with the same settings and compare the results, not one lucky score. Lighthouse emulates a mid-range phone on a throttled connection by default, which is what the mobile score in PageSpeed Insights uses too:

#!/usr/bin/env bash
# Three mobile Lighthouse runs, then the numbers that matter from each report.
set -euo pipefail
URL="${1:?Usage: ./lh.sh https://example.com/}"

for i in 1 2 3; do
  npx lighthouse "$URL" \
    --only-categories=performance \
    --output=json --output-path="./lh-$i.json" \
    --chrome-flags="--headless=new" --quiet
done

for f in lh-*.json; do
  node -e '
    const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
    const a = r.audits;
    console.log(process.argv[1],
      "score", Math.round(r.categories.performance.score * 100),
      "LCP", a["largest-contentful-paint"].displayValue,
      "CLS", a["cumulative-layout-shift"].displayValue,
      "TTFB", Math.round(a["server-response-time"].numericValue) + " ms");
  ' "$f"
done

Find out what the LCP element is waiting for

Start with the LCP breakdown in the report. On my home page the LCP element is the hero headline, which is text, not an image. A text LCP has no image to download; what it waits for is the font. Web fonts are discovered late: the browser only learns the woff2 URL after it has downloaded and parsed the stylesheet that declares it. Until the file arrives, the headline either waits or paints in a fallback font and repaints.

So the headline sat at the end of a chain: the HTML, then a stylesheet, then the font file that stylesheet declares. Every step on a throttled mobile connection costs a round trip. The fix has two parts: fewer fonts, and start the one that matters earlier.

Cut five font families down to two

The site was loading five font families from Google, including DM fonts left over from an old snippet. The new design needs only two: IBM Plex Sans for everything and IBM Plex Mono for the code-styled details. I removed the snippet, kept only those two families, and turned on Elementor’s option to load Google Fonts locally.

Serving the fonts from your own domain removes two extra origins (fonts.googleapis.com and fonts.gstatic.com), and each one costs a DNS lookup and a TLS handshake on mobile. It also stops sending every visitor’s IP address to a third party, which your privacy policy will appreciate.

To find stray fonts, filter the DevTools Network panel by “Font” and search the page source for googleapis. Old fonts usually hide in Elementor Custom Code, the Customizer’s Additional CSS, a snippets plugin, or a single widget with a font family set directly instead of through a global.

Preload the one file the headline needs

IBM Plex Sans is served as a variable font, so a single Latin woff2 covers every weight in normal style. That’s the file to preload. The catch is that Elementor writes the local font files itself, and their names aren’t something you should hard-code. The child theme reads Elementor’s local font CSS, finds the Latin normal woff2, caches the URL in a transient and prints the preload early in <head>:

<?php
/**
 * Preload the Latin woff2 of IBM Plex Sans that Elementor serves locally,
 * so the hero headline (the LCP element) doesn't wait for the CSS chain.
 */
function rw_preload_primary_font() {
	$uploads = wp_get_upload_dir();
	$css     = $uploads['basedir'] . '/elementor/google-fonts/css/ibmplexsans.css';
	if ( ! is_readable( $css ) ) {
		return;
	}

	$mtime  = (int) filemtime( $css );
	$cached = get_transient( 'rw_plex_latin_woff2' );

	if ( ! is_array( $cached ) || $cached['mtime'] !== $mtime ) {
		$url = '';
		$raw = (string) file_get_contents( $css ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		// First normal-style @font-face after a /* latin */ comment.
		if ( preg_match( '#/\*\s*latin\s*\*/\s*@font-face\s*\{[^}]*font-style:\s*normal;[^}]*url\(\s*[\'"]?([^\'")]+\.woff2)#', $raw, $m ) ) {
			$url = $m[1];
		}
		$cached = [ 'mtime' => $mtime, 'url' => $url ];
		set_transient( 'rw_plex_latin_woff2', $cached, DAY_IN_SECONDS );
	}

	if ( $cached['url'] ) {
		printf(
			'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
			esc_url( $cached['url'] )
		);
	}
}
add_action( 'wp_head', 'rw_preload_primary_font', 1 );

A few details that make or break this:

  • crossorigin is required, even for a font on your own domain. Fonts are always fetched in CORS mode, and a preload without the attribute doesn’t match the real request, so the browser downloads the file twice.
  • Priority 1 on wp_head puts the tag near the top of <head>, ahead of the stylesheets, so the download starts with the HTML.
  • The regex skips latin-ext and italic faces. [^}]* keeps each match inside one @font-face block.
  • Preload one file, not the whole family. Every preload competes for bandwidth with the CSS the page needs to render.

The version on my site simply caches the URL for a day. Storing the CSS file’s modification time next to it, as above, is a refinement worth making: if Elementor regenerates its fonts, the next request notices and the preload never points to a file that no longer exists. See MDN’s rel="preload" reference and web.dev’s Optimize LCP for the underlying rules.

Images that reserve their space

The project covers are 1280×800 WebP files, rendered with explicit width and height attributes. With both attributes present, the browser knows the aspect ratio before the file arrives and reserves the box, which is why Cumulative Layout Shift stays at 0 while the grid loads. WordPress adds the attributes when an image comes from the media library; images pasted as raw HTML or set as CSS backgrounds are where they go missing.

This console snippet checks a page in a few seconds:

// Paste into the DevTools console: lists images that can shift the layout or slow LCP.
const rows = [];
for (const img of document.images) {
  const box = img.getBoundingClientRect();
  if (box.width === 0) continue; // Hidden, like the logo for the other color mode.
  const issues = [];
  if (!img.hasAttribute('width') || !img.hasAttribute('height')) {
    issues.push('no width/height');
  }
  if (box.top < window.innerHeight && img.loading === 'lazy') {
    issues.push('lazy-loaded above the fold');
  }
  if (img.naturalWidth > box.width * window.devicePixelRatio * 1.5) {
    issues.push(`served ${img.naturalWidth}px for ${Math.round(box.width)}px`);
  }
  if (issues.length) rows.push({ src: img.currentSrc, issues: issues.join('; ') });
}
console.table(rows);

An image above the fold should never be lazy-loaded. If your LCP element is an image rather than text, it should also be the one with fetchpriority="high".

What’s still on the table

A 99 isn’t a perfect page, and the report still lists three opportunities. I’d rather name them than hide them:

  • Server response time is about 650 ms. There’s no page cache yet, so WordPress builds every page on every request. A page cache is the biggest remaining win (see web.dev on TTFB). Before you switch one on, test anything that prints a nonce into the HTML, such as forms: WordPress nonces expire within a day, and a cached page can outlive them.
  • Roughly 50 KB of unused JavaScript from jQuery and Elementor’s own runtime. With TBT at 0 ms, it costs bytes rather than main-thread time. Removing either means fighting the page builder for a small gain.
  • Render-blocking CSS from Elementor’s per-document files: the kit, header, footer and page each get their own stylesheet. Inlining critical CSS or changing how Elementor prints its CSS can help, but both trade cache reuse across pages for a faster first view, so measure before and after.

What to watch out for

  • Old snippets and Custom Code that bring Google Fonts back after you’ve switched to local fonts. Check the Network panel after every plugin or snippet change.
  • Hard-coded preload URLs. When Elementor regenerates its local fonts, a hard-coded URL turns into a 404 that still costs a request.
  • Preloading fonts that don’t appear above the fold, or several weights of a static font. Each one delays the CSS.
  • A lazy-loaded LCP image, often from a slider or a lazy-loading plugin that ignores the first image.
  • Lab data isn’t field data. Once the site has traffic, confirm the gains in the Chrome UX Report and Search Console’s Core Web Vitals report.

This is the kind of work I take on for clients: Elementor development