My site scored 84 on mobile Lighthouse. Desktop was fine, but mobile kept losing points to two metrics I had been ignoring: Total Blocking Time and Cumulative Layout Shift. This post walks through exactly what caused them, how I traced each one, and the small fixes that took the score to 97. Nothing here requires a plugin, and every change is a few lines of code.
Reading the report properly
The mobile score was made of good paint numbers (First Contentful Paint at 1.0s, Largest Contentful Paint at 1.0s) dragged down by 230ms of Total Blocking Time and a Cumulative Layout Shift of 0.129. That combination tells a specific story: the page arrives fast, then JavaScript hogs the main thread, and something reflows the layout after first paint.
Lighthouse hides the useful details below the score. Two audits told me everything: “JavaScript execution time” attributed 912ms of scripting to my own theme file, and “Avoid large layout shifts” pointed at the hero section’s inner container. Everything else was noise.
Fixing Total Blocking Time: the canvas animation was too eager
My theme draws a floating particle animation on the homepage hero using a canvas and requestAnimationFrame. The draw loop connects nearby particles with lines, which means an O(n²) pass over every pair of particles on every frame. With 50 particles that is 1,225 distance checks per frame. On a desktop CPU that is nothing. Under the 4x CPU throttle Lighthouse applies to simulate a mid-range phone, each frame became a long task, and long tasks are exactly what Total Blocking Time counts.
The fix was to scale the particle count with the canvas area instead of hardcoding it:
resize();
// Scale particle count with canvas area so small screens do less per-frame work.
const count = Math.min( 50, Math.round( ( w * h ) / 12000 ) );
A 390px-wide phone hero now gets around 20 particles, which cuts the pair checks from 1,225 to under 200 per frame. Visually the density looks the same because the canvas is smaller. Total Blocking Time went from 230ms to 10ms. The lesson generalizes: any decorative animation should budget its per-frame work against the weakest device you care about, not the laptop you built it on.
Fixing Cumulative Layout Shift: the font swap
The layout shift came from self-hosted web fonts loading with font-display: swap. The browser paints the hero heading in a fallback font, the woff2 files arrive a moment later, and the swap reflows the whole hero because the two fonts have different metrics. That single reflow was worth 0.118 of my 0.129 CLS.
The cleanest fix for self-hosted fonts is to preload them so they are already in the cache when the browser paints the first frame. WordPress has a filter for exactly this since 6.1, so there is no need to echo link tags into wp_head by hand:
function mytheme_preload_fonts( $resources ) {
foreach ( array( 'SpaceGrotesk-Variable-Latin.woff2', 'Inter-Variable-Latin.woff2' ) as $font ) {
$resources[] = array(
'href' => get_template_directory_uri() . '/assets/fonts/' . $font,
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => 'anonymous',
);
}
return $resources;
}
add_filter( 'wp_preload_resources', 'mytheme_preload_fonts' );
Two details matter here. The crossorigin attribute is required even for same-origin fonts, because font requests always use CORS mode; without it the browser downloads the font twice. And this only makes sense for subsetted fonts. My two variable fonts are Latin subsets weighing 23KB and 49KB, so preloading them is cheap. Preloading a 300KB font family would trade layout shift for slower paint.
CLS dropped from 0.129 to under 0.01.
The render-blocking script that did not need to block
PageSpeed Insights also flagged my theme’s JavaScript as render blocking, worth around 600ms on a throttled connection. The file only registers DOMContentLoaded handlers, so there was no reason for it to block parsing. Since WordPress 6.3 you can declare a loading strategy right in the enqueue instead of filtering script tags:
wp_enqueue_script(
'mytheme-main',
get_template_directory_uri() . '/assets/js/theme.js',
array(),
MYTHEME_VERSION,
array(
'in_footer' => true,
'strategy' => 'defer',
)
);
After the next deploy the script disappeared from the render-blocking list entirely.
Measure honestly
Two measurement traps cost me an hour each. First, local Lighthouse runs on a busy laptop are noisy; my scores swung between 82 and 97 across identical runs because headless Chrome sometimes painted late for reasons unrelated to the site. Take the median of three runs, or trust PageSpeed Insights, which runs on consistent hardware. Second, make sure you are measuring the page you think you are measuring. My CDN was serving browsers an HTML copy that was hours old, so my first “after” runs were actually measuring the “before” page. Check the age and cache status response headers before believing any number.
Final result: mobile 97, desktop 100, and every fix was a handful of lines. The score was never the point, though. Total Blocking Time and Cumulative Layout Shift are the two metrics that map most directly to how janky a page feels on a real phone, and those are now effectively zero.