Tutorials

Better Performance: Preloading, Deferred Scripts, and Layout Stability without Plugins

Lighthouse and PageSpeed Insights score three metrics that have nothing to do with server speed: Largest Contentful Paint, Total Blocking Time, and Cumulative Layout Shift. All three are fixable from header.php and functions.php — the same no-plugin approach as the SEO posts on this blog.

1. Preload the LCP image

On most posts, the largest contentful paint is the featured image. By default the browser doesn’t discover it until it parses the HTML far enough to hit the <img> tag. A preload hint in <head> tells it to start the request immediately, in parallel with the HTML itself:

<?php if ( is_singular() && has_post_thumbnail() ) : ?>
<link rel="preload" as="image" fetchpriority="high"
	href="<?php echo esc_url( get_the_post_thumbnail_url( get_the_ID(), 'large' ) ); ?>" />
<?php endif; ?>

Put this above the stylesheet links, as early in <head> as possible — the point is to win the race against everything else on the page.

2. Defer non-critical scripts, drop jQuery Migrate

Every enqueued script blocks parsing unless it’s marked defer or async. Theme scripts that only touch the DOM (menus, sliders, form handling) are safe to defer. jQuery Migrate is dead weight on any theme that isn’t calling deprecated jQuery APIs:

<?php
add_filter( 'script_loader_tag', function ( $tag, $handle ) {
	$defer = array( 'visualculture-scripts', 'comment-reply' );
	if ( in_array( $handle, $defer, true ) ) {
		return str_replace( ' src', ' defer src', $tag );
	}
	return $tag;
}, 10, 2 );

add_action( 'wp_default_scripts', function ( $scripts ) {
	if ( is_admin() || empty( $scripts->registered['jquery'] ) ) {
		return;
	}
	$jquery = $scripts->registered['jquery'];
	$jquery->deps = array_diff( $jquery->deps, array( 'jquery-migrate' ) );
} );

3. font-display: swap on custom fonts

A custom @font-face with no font-display holds text invisible until the font file arrives — the classic FOIT. font-display: swap paints with a fallback font first and swaps in the real one when it’s ready, which removes that block from Total Blocking Time. Preloading the file removes the flash-of-fallback delay too:

@font-face {
	font-family: 'Theme Sans';
	src: url('/wp-content/themes/visualculture/fonts/theme-sans.woff2') format('woff2');
	font-weight: 400;
	font-display: swap;
}
<link rel="preload" as="font" type="font/woff2" crossorigin
	href="<?php echo get_template_directory_uri(); ?>/fonts/theme-sans.woff2" />

4. Reserve space for embeds

WordPress has added width/height to content images automatically since 5.5, so layout shift from those is mostly solved already. oEmbeds (a YouTube URL on its own line, a tweet) are the remaining offender — the iframe has no dimensions until it loads. Wrap the embed in a sized container so the layout never has to move:

<?php
add_filter( 'embed_oembed_html', function ( $html ) {
	if ( false !== strpos( $html, '<iframe' ) ) {
		$html = '<div class="embed-container">' . $html . '</div>';
	}
	return $html;
} );
.embed-container {
	position: relative;
	aspect-ratio: 16 / 9;
}
.embed-container iframe {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
}

Re-run the numbers with PageSpeed Insights or the Lighthouse panel in Chrome DevTools after each change — the four fixes here target LCP, TBT, and CLS independently, so it’s worth confirming which one actually moved the needle.