WordPress Web Performance Optimization

Last year, I set out to see how much I could improve my site’s performance without relying on commercial performance plugins. Almost everything I’ve implemented has been completely free. The only exception is an image resizing service I host on DigitalOcean for $5/month—though I suspect the results would be similar without it. My goal was to make these optimizations accessible to anyone.

Without compromising functionality, I’ve achieved a 97 on the Lighthouse performance score. Here’s how you can do it too.

Measuring Performance with WebPageTest and Lighthouse

Before making optimizations, it’s important to measure where your site stands. Two of the best tools for this are WebPageTest and Lighthouse.

WebPageTest

WebPageTest provides a detailed breakdown of your site’s performance, including:

  • Time to First Byte (TTFB): Measures server response time.
  • First Contentful Paint (FCP): When the first piece of content is visible.
  • Largest Contentful Paint (LCP): Measures how long it takes for the most important content to load.
  • Cumulative Layout Shift (CLS): Tracks visual stability.
  • Fully Loaded Time: The time until all page elements are completely loaded.

One of WebPageTest’s most useful features is the waterfall chart, which visually represents the sequence and timing of all network requests made by the browser. This allows you to:

  • Identify render-blocking resources slowing down your page.
  • Spot slow-loading assets, such as oversized images or unoptimized scripts.
  • See parallel vs. sequential loading patterns, helping you determine whether resources are efficiently loaded.

Lighthouse

Lighthouse is built into Chrome DevTools and provides insights into:

  • Performance: Page speed, render-blocking resources, and improvements.
  • Accessibility: How user-friendly the site is.
  • Best Practices: Ensuring up-to-date security and coding standards.
  • SEO: Whether your site is optimized for search engines.

To run Lighthouse:

  1. Open Chrome DevTools (Right-click > Inspect or Cmd+Option+I / Ctrl+Shift+I).
  2. Go to the Lighthouse tab.
  3. Click Analyze Page Load to generate a report.

Using these tools, you can identify performance bottlenecks, apply the optimizations outlined in this guide, and then retest to measure improvements.

Pick Plugins Carefully

It may seem obvious, but it’s worth emphasizing: Be selective about the plugins you install. I almost left this section out—until I installed a popular contact form plugin and discovered it injected JavaScript and CSS on every single page, even where no forms were used. This kind of bloat slows down your site unnecessarily.

Full Page Cache

Caching HTML at the server level is easy and makes a big impact. I recommend caching HTML in Nginx, but there are many caching plugins available. Additionally, adding a CDN for static assets can further improve performance.

Defer JavaScript and CSS

Since WordPress 6.3, we have had the ability to register scripts with async or defer. Still, I don’t see many plugins taking advantage of this. I’ve been using Mark Jaquith’s encute to defer scripts (and styles!)

For reference:

  • async: The script is fetched in parallel with the page and executed as soon as it’s ready. Great for progressive enhancement scripts.
  • defer: The script loads in parallel but executes only after the document has been parsed. Ideal for analytics scripts and non-critical JavaScript.

Lazy Load Images

One of the keys ended up being to lazy load all images after the first post. By default WordPress lazy loads all images except for the first 3. I don’t post that many images. Having an image half way down the page that has fetchpriority=high was consistently impacting the FCP and LCP. This was probably more important than the image resizing service I mentioned above because it ensures the images are not loaded at all until they’re needed.

Embed Facades

Embedding YouTube videos can have a big impact. Fortunately, Paul Irish’s Lite YouTube Embed replaces the standard embed with a static image and some CSS. When the user clicks the “video” it is replaced with the actual embed. This clever optimization ensures the video is not loaded at all unless you really need it. Similar facades exist for other types of embeds as well.

Inline Critical CSS

Inlining critical CSS ensures above-the-fold content loads quickly. You can use a package like critical in your theme to extract and inline critical styles. You might as well minify your css with something like postcss and cssnano while you’re at it. To defer non-critical styles, I’ve added this snippet to my theme:

// Defer non-critical styles
add_filter( 'style_loader_tag', function($html, $handle, $href, $media) {
	if ( ! in_array( $handle, ['josh.blog'] ) ) {
		return $html;
	}

	$html = sprintf(
		'<link rel="stylesheet" id="%s-css" href="%s" media="print" onload="this.media=\'all\'">',
		esc_attr($handle),
		esc_url($href)
	);

    return $html;
}, 10, 4 );

Resource Hints

Resource hints allow browsers to anticipate and optimize how they load resources. The most useful ones for performance are:

  • preconnect: Establishes an early connection to an external domain to speed up subsequent requests. Ideal for CDNs and third-party services like Google Fonts.
  • dns-prefetch: Helps resolve domain names early to reduce latency.
  • prefetch: Loads low-priority resources in the background for future navigation.
  • prerender: Fully loads and renders an entire page in the background for seamless navigation.

WordPress has a filter to manage resource hints: wp_resource_hints. You can use it to add preconnect and dns-prefetch hints dynamically:

function add_resource_hints($hints, $relation_type) {
    if ('preconnect' === $relation_type) {
        $hints[] = 'https://fonts.googleapis.com';
    }
    if ('dns-prefetch' === $relation_type) {
        $hints[] = 'https://cdnjs.cloudflare.com';
    }
    return $hints;
}
add_filter('wp_resource_hints', 'add_resource_hints', 10, 2);

Preload Web Fonts

Web fonts can significantly impact performance if not handled correctly. By preloading your primary fonts, you ensure they are available earlier in the page load process, reducing layout shifts and rendering delays.

To preload a web font, use:

<link rel="preload" href="<?php echo get_stylesheet_directory_uri(); ?>/fonts/montserrat-v18-latin-regular.woff2" crossorigin="anonymous" as="font" type="font/woff2">

Best practices for web font optimization:

  1. Use modern formats: Prefer WOFF2 for smaller file sizes and better compression.
  2. Subset fonts: Reduce font file size by including only the necessary character sets.
  3. Load critical fonts first: Preload body and heading fonts, but avoid preloading too many fonts to prevent resource contention.
  4. Use font-display: swap: Allows text to render with a fallback font while the custom font loads.
@font-face {
    font-family: 'Montserrat';
    src: url('/fonts/montserrat-v18-latin-regular.woff2') format('woff2');
    font-display: swap;
}

Preloading and optimizing web fonts properly can help eliminate render-blocking issues and improve perceived performance.

Conclusion

While working on this, I noticed some claims that WordPress is inherently slower than JavaScript frameworks. This shows WordPress can be as fast as anything else.

Lazy Load WordPress Images

Since WordPress 5.5, WordPress has started enabling lazy loading on images. This was dramatically improved in WordPress 6.3.

At the same time, the default value of wp_omit_loading_attr_threshold changed from 1 to 3, meaning the first 3 images will not be lazy loaded, and in fact will have fetchpriority=high. This makes sense for a typical homepage or the first blog post, but less so for the 2nd or 3rd blog post on a page.

I wrote a filter to force enable lazy loading after the first blog post. This ensures that even if the first image is farther down the page, it will not have fetchpriority=high.

// Lazy load all images after the first post
add_filter('wp_get_loading_optimization_attributes', function ($attrs, $tag, $attr, $context) {
	if (is_admin() || !is_main_query()) {
		return $attrs;
	}

	if ($tag !== 'img') {
		return $attrs;
	}

	global $wp_query;
	if ($wp_query->posts[0]->ID === get_the_ID()) {
		return $attrs;
	}

	$attrs['loading'] = 'lazy';
	$attrs['fetchpriority'] = null;
	return $attrs;
}, 10, 4);

Mozilla Money Problems

I still use Firefox Developer Edition regularly. In my opinion, it still has the best dev tools. However, it’s hard to see how they keep it going without funding from Google search.

We’ve recognized that Mozilla faces major headwinds in terms of both financial growth and mission impact. While Firefox remains the core of what we do, we also need to take steps to diversify: investing in privacy-respecting advertising to grow new revenue in the near term; developing trustworthy, open source AI to ensure technical and product relevance in the mid term; and creating online fundraising campaigns that will draw a bigger circle of supporters over the long run.

Provisional Authorization of User Notifications

Requesting provisional authorization to send local user notifications comes with some caveats but avoids interrupting the user with another permissions prompt.

https://useyourloaf.com/blog/provisional-authorization-of-user-notificatons

This is arguably a better user experience than requesting permission to send notifications the first time an app launches without any other context.

Cloudflare as a Free CDN for WordPress Assets

Why Use a CDN for Assets?

A Content Delivery Network (CDN) helps speed up a website by caching static assets (like JavaScript and CSS) on servers distributed around the world. This reduces latency and offloads traffic from the origin server, improving load times and user experience.

Cloudflare has a WordPress plugin that will help you cache everything, including dynamic content, on the Cloudflare edge. I didn’t want to do that, preferring more control over caching HTML content and less complexity around invalidating the Cloudflare cache. However, I still want to take advantage of Cloudflare’s CDN to cache my site’s static assets. I’ve found that you can still get great performance without proxying dynamic content through Cloudflare. (I do cache full HTML pages in Nginx.)

I’m already serving images from Cloudflare the imgproxy plugin I mentioned previously. It’s harder to target image URLs in WordPress posts, but you could use that plugin for inspiration.

Setting Up Cloudflare for WordPress Assets

Instead of placing my whole domain behind Cloudflare, I added a new subdomain dedicated to serving assets:

1. Add a new domain (or subdomain) to Cloudflare

  • In Cloudflare, I added cdn.example.com as a new site.
  • This domain is configured as a CNAME pointing to my WordPress site.

2. Enable Cloudflare caching

  • Since WordPress appends query strings for versioning (e.g., ?ver=6.4.2), new styles and scripts will always be available right after updating.

3. Ensure the subdomain serves assets correctly

  • If your web server allows wildcard domains, this should work without any additional configuration.
  • WordPress itself doesn’t care about the domain name for assets.

At this point, assets should be accessible via cdn.example.com, but WordPress is still serving them from the main domain. The next step is to rewrite asset URLs to use the CDN. I recommend testing that you can load static assets from Cloudflare before continuing. A good test is https://cdn.example.com/wp-includes/css/editor.min.css. That should load the expected stylesheet before switching asset URLs to your new CDN domain.

Rewriting WordPress Asset URLs

To make WordPress use the CDN, I created a small plugin to rewrite script and style URLs. This plugin ensures that any assets hosted on my WordPress site are served from cdn.example.com instead.

<?php

namespace CFCDN;

// Define your CDN URL
define( 'CDN_URL', 'https://cdn.example.com' );

/**
 * Replace the URL with CDN URL.
 *
 * @param string $url The original URL.
 * @return string The modified URL with CDN.
 */
function replace_url_with_cdn( $url ) {
    // Only modify the URL if it's a local URL
    $host = parse_url( $url, PHP_URL_HOST );
    $home_host = parse_url( home_url(), PHP_URL_HOST );
    if ( $host === $home_host ) {
        $parsed = parse_url( CDN_URL );
        $replace = $parsed['scheme'] . '://' . $parsed['host'];

        // Replace the site's URL with the CDN URL
        $url = str_replace( home_url(), $replace, $url );
    }

    return $url;
}

/**
 * Use the CDN URL everywhere we currently use a stylesheet URL
 *
 * For example, the favicon URL and preload URLs.
 * @param string stylesheet_dir_uri The source URL of the script.
 * @return string The modified script URL.
 */
function stylesheet_directory_uri( $stylesheet_dir_uri ) {
    return replace_url_with_cdn( $stylesheet_dir_uri );
}
add_filter( 'stylesheet_directory_uri', 'CFCDN\stylesheet_directory_uri' );

/**
 * Enqueue CDN URLs for scripts.
 *
 * @param string $src The source URL of the script.
 * @param string $handle The script's registered handle.
 * @return string The modified script URL.
 */
function enqueue_cdn_scripts( $src, $handle ) {
    return replace_url_with_cdn( $src );
}
add_filter( 'script_loader_src', 'CFCDN\enqueue_cdn_scripts', 10, 2 );

/**
 * Enqueue CDN URLs for styles.
 *
 * @param string $src The source URL of the style.
 * @param string $handle The style's registered handle.
 * @return string The modified style URL.
 */
function enqueue_cdn_styles( $src, $handle ) {
    return replace_url_with_cdn( $src );
}
add_filter( 'style_loader_src', 'CFCDN\enqueue_cdn_styles', 10, 2 );

This plugin works by:

  • Checking if the URL belongs to the WordPress site.
  • Rewriting it to use the CDN domain instead.
  • Applying this change to all scripts, styles, and even theme-related assets.

Once activated, WordPress automatically loads JavaScript and CSS from the Cloudflare CDN. It’s worth noting that this even serves scripts and styles from the CDN in wp-admin.

Final Thoughts

This approach lets me use Cloudflare’s caching for JavaScript and CSS assets without putting my entire site behind Cloudflare. The setup is straightforward:

  • Create a new Cloudflare domain as a CNAME to WordPress.
  • Enable caching for static assets.
  • Rewrite asset URLs using a small WordPress plugin.

With minimal effort, this improves page speed, reduces server load, and provides a better user experience. If you’re looking for a simple way to boost your WordPress site’s performance, this method is worth considering.