Skip to content

WordPress Speed Optimization: 95 Mobile PageSpeed Score

By Jasper Frumau

As a WordPress developer serving European SMEs, I spend a lot of time optimizing client sites for speed. But was I practicing what I preach on my own site?

This month, I decided to audit imagewize.com and fix every performance bottleneck I could find. The results? Desktop PageSpeed jumped from 73 to 99, and mobile climbed to 95/100.

Here’s exactly how I did it—with real code examples, Core Web Vitals improvements, and technical SEO wins you can implement on your own WordPress site.

The Starting Point: PageSpeed 73 Desktop

Before diving into optimizations, I ran a baseline PageSpeed Insights audit. The results weren’t terrible, but they weren’t great either:

Before Optimization (Desktop):

  • Performance: 73/100 ⚠️
  • Primary Issue: Render-blocking CSS (930ms+ blocking time)
  • Hero Image: 70 KiB loaded but only 576×334px displayed (~60 KiB wasted)
  • LCP Resource Load Delay: 2,360ms
  • CLS (Cumulative Layout Shift): 0.602 (target: <0.1)
IssueImpactTechnical Cause
Render-blocking CSS930ms blockingWooCommerce CSS (16 KiB), Block Library (15.7 KiB), 15+ custom block styles loading synchronously
Oversized hero image60 KiB wasted bandwidthServing 1500×871px image for 576×334px display
LCP delay2,360msHero image had loading="lazy" attribute on above-the-fold content
Layout shift (CLS 0.602)Poor user experienceWeb fonts (Open Sans) loading after initial render, causing text reflow

For a site with PHP-FPM page caching, Redis object cache, and WebP images already enabled, these were fixable problems—not infrastructure issues.

Optimization 1 – Async CSS Loading (300-400ms Savings)

The Problem

WordPress and WooCommerce were loading ALL CSS synchronously in the <head>, even for below-the-fold content. The browser had to download and parse ALL of these before rendering anything.

The Solution

Load non-critical CSS asynchronously using the “print media” technique. I added this filter to my theme’s app/filters.php:

/**
 * Make non-critical stylesheets non-render-blocking
 * Uses the "print media" technique to load CSS asynchronously
 */
add_filter('style_loader_tag', function ($html, $handle) {
    // List of non-critical stylesheets to load asynchronously
    $async_styles = [
        // Classic WooCommerce styles
        'woocommerce-layout',
        'woocommerce-general',
        'brands-styles',

        // WooCommerce Blocks styles
        'wc-blocks-style',
        'wc-blocks-vendors-style',

        // WordPress core block library (15.7 KiB)
        'wp-block-library',

        // Custom block styles (below-the-fold on homepage)
        'imagewize-feature-list-grid-style',
        'imagewize-testimonial-grid-style',
        'imagewize-carousel-style',
        // ... (15+ more block styles)

        // Other
        'slick-carousel',
    ];

    if (in_array($handle, $async_styles, true)) {
        // Change media to print, swap to all on load
        $html = str_replace(
            "media='all'",
            "media='print' onload=\"this.media='all'\"",
            $html
        );
    }

    return $html;
}, 10, 2);

How It Works

  1. CSS loads with media='print' (browser doesn’t block render)
  2. JavaScript onload event swaps to media='all' after download
  3. CSS applies without blocking initial page paint

⚠️ Critical Warning:

Do NOT async-load stylesheets with specific media queries! For example, woocommerce-smallscreen has media='only screen and (max-width: 768px)'. If you async-load it, the technique changes media to print, then swaps to all on load, making mobile-only styles apply at ALL viewport sizes. Result: WooCommerce shop grid breaks (2 columns instead of 4 on desktop).

Impact:

  • wp-block-library (15.7 KiB) – Previously 200ms blocking
  • 15 custom block styles (~12 KiB total) – Previously 120ms each
  • Combined estimated savings: ~300-400ms render-blocking time

Optimization 2 – Responsive Hero Images (~60 KiB Savings)

The Problem

PageSpeed reported my hero image (hero.webp) was:

  • Served at: 1500×871 pixels (69.8 KiB)
  • Displayed at: 576×334 pixels
  • Wasted bandwidth: 59.5 KiB

That’s 85% of the image size going unused!

The Solution

Create custom WordPress image sizes and use wp_get_attachment_image() for automatic srcset generation.

Step 1: Register Custom Image Sizes

In app/setup.php:

// Hero block responsive images (displayed at ~576x334 on desktop)
add_image_size('hero-desktop', 600, 348, true);      // 1x for standard displays
add_image_size('hero-desktop-2x', 1200, 696, true);  // 2x for retina displays

Step 2: Update HeroBlock.php

// Generate responsive image HTML for desktop
$desktop_image_html = wp_get_attachment_image(
    $desktop_image['ID'],
    'hero-desktop',
    false,
    [
        'class' => 'desktop-only-img object-cover w-full h-full',
        'loading' => 'lazy',  // We'll fix this in Optimization 3!
        'sizes' => '(min-width: 1024px) 576px, 50vw',
    ]
);

Step 3: Regenerate Thumbnails

cd /srv/www/imagewize.com/current
wp media regenerate --only-missing --path=web/wp
DisplayImage Size LoadedFile Size (WebP)Savings
Standard display (1x)hero-desktop 600×34817 KiB53 KiB saved (76%)
Retina display (2x)hero-desktop-2x 1200×69639 KiB31 KiB saved (44%)
Before (full size)1500×87170 KiB

Impact:

  • Standard displays: 53 KiB saved (76% reduction)
  • Retina displays: 31 KiB saved (44% reduction)
  • Faster LCP (Largest Contentful Paint)

Optimization 3 – Hero Image Eager Loading (~2s LCP Improvement)

The Problem

PageSpeed’s LCP breakdown showed:

Resource load delay: 2,360 ms  ← THIS IS THE PROBLEM
Resource load duration: 270 ms
Element render delay: 80 ms

The hero image was the LCP element but had loading="lazy", which delays loading until the browser determines if the image is in the viewport. For above-the-fold images, this adds massive delay.

The Solution

Change hero image from loading="lazy" to loading="eager" and add fetchpriority="high".

$desktop_image_html = wp_get_attachment_image(
    $desktop_image['ID'],
    'hero-desktop',
    false,
    [
        'class' => 'desktop-only-img object-cover w-full h-full',
        'loading' => 'eager',  // Changed from 'lazy'
        'fetchpriority' => 'high',  // Hint browser to prioritize this image
        'sizes' => '(min-width: 1024px) 576px, 50vw',
    ]
);

Why This Works:

  • loading="eager": Browser loads image immediately (default behavior)
  • fetchpriority="high": Browser prioritizes this image over other resources
  • LCP element loads as fast as possible without artificial delay

Impact:

  • LCP resource load delay: 2,360ms → ~100ms (eliminate lazy load penalty)
  • Overall LCP improvement: ~2+ seconds

Optimization 4 – Font Preloading (CLS Fix: 0.602 → <0.1)

The Problem

PageSpeed reported CLS (Cumulative Layout Shift) of 0.602 (target is <0.1). When the Open Sans web font loaded, text reflowed and shifted the entire <main> element by 0.602 units.

The Solution

Preload critical fonts in <head> so they’re available before first paint.

Added to app/setup.php:

/**
 * Preload critical fonts to prevent CLS (Cumulative Layout Shift)
 */
add_action('wp_head', function () {
    $manifest_path = get_theme_file_path('public/build/manifest.json');
    if (!file_exists($manifest_path)) {
        return;
    }

    $manifest = json_decode(file_get_contents($manifest_path), true);

    // Preload Open Sans regular (most used weight)
    $regular_font = $manifest['resources/fonts/open-sans-v40-latin-regular.woff2'] ?? null;
    if ($regular_font) {
        printf(
            '<link rel="preload" as="font" type="font/woff2" href="%s" crossorigin="anonymous">',
            esc_url(get_theme_file_uri('public/build/' . $regular_font))
        );
    }

    // Preload Open Sans semibold (headings)
    $semibold_font = $manifest['resources/fonts/open-sans-v40-latin-600.woff2'] ?? null;
    if ($semibold_font) {
        printf(
            '<link rel="preload" as="font" type="font/woff2" href="%s" crossorigin="anonymous">',
            esc_url(get_theme_file_uri('public/build/' . $semibold_font))
        );
    }
}, 1);

How It Works:

  1. <link rel="preload" as="font"> tells browser to download fonts immediately
  2. crossorigin="anonymous" required for CORS with fonts
  3. Fonts are available before first paint → no layout shift
  4. Uses Vite manifest to resolve hashed font filenames (cache-busting)

Impact:

  • CLS: 0.602 → <0.1 (target achieved)
  • Improved perceived performance (no text reflow on load)

Optimization 5 – Database Cleanup (608 KB → 251 KB Autoload)

The Problem

WordPress’s wp_options table had bloated autoloaded data from deactivated plugins:

Option NameSizeSource
wprc_info_extension227 KBOld WPML Installer plugin (2014)
wp_installer_settings138 KBOld WPML Installer settings (2014)

Why This Matters: Autoloaded options are queried and unserialized on every WordPress page load. 608 KB of data being processed on every request = memory pressure and slower performance.

The Solution

Step 1: Audit Autoloaded Options

wp db query "SELECT option_name, LENGTH(option_value) as option_size
             FROM wp_options WHERE autoload='yes'
             ORDER BY option_size DESC LIMIT 10;" --path=web/wp

Step 2: Backup Database

wp db export backup_$(date +%Y%m%d_%H%M%S).sql.gz --path=web/wp

Step 3: Delete Orphaned Options

wp option delete wprc_info_extension --path=web/wp
wp option delete wp_installer_settings --path=web/wp
MetricBeforeAfterChange
Options count844842-2 (-0.2%)
Total size608 KB251 KB-357 KB (-58.7%)
Largest option228 KB18 KB-210 KB

Impact:

  • Faster page loads (less data to query/unserialize)
  • Reduced memory usage per request
  • Better database performance

Optimization 6 – WordPress Memory Limits (Fix Hidden Cap)

The Problem

While optimizing, I discovered WordPress was capping its own memory usage at 40MB regardless of PHP’s 512MB limit. With WooCommerce + Acorn/Laravel, each request needs ~150-250MB. The 40MB cap was causing silent failures.

The Solution

Added to site/config/application.php:

/**
 * Memory Limits
 *
 * WP_MEMORY_LIMIT: Memory for frontend requests (WordPress default: 40M)
 * WP_MAX_MEMORY_LIMIT: Memory for admin/backend requests (WordPress default: 256M)
 *
 * Increased for WooCommerce + Acorn/Laravel which require more memory.
 */
Config::define('WP_MEMORY_LIMIT', '256M');
Config::define('WP_MAX_MEMORY_LIMIT', '512M');
SettingPurposeDefaultOur Value
WP_MEMORY_LIMITMemory for frontend/visitor requests40M256M
WP_MAX_MEMORY_LIMITMemory for admin/backend requests256M512M
php_memory_limitPHP-FPM’s overall limit (in Trellis)512M1024M

Impact:

  • Eliminated “memory exhausted” errors on admin pages
  • WooCommerce product saves no longer fail
  • Acorn cache compilation succeeds on first try

Results – Before/After Comparison

PageSpeed Scores

MetricBefore (Desktop)After (Desktop)Before (Mobile)After (Mobile)
Performance7399 ✅ (+26 points)~85 (est.)95

Core Web Vitals

MetricBeforeAfterTargetStatus
LCP (Largest Contentful Paint)~3.5s~1.4s< 2.5s✅ PASS
CLS (Cumulative Layout Shift)0.602<0.1< 0.1✅ PASS
INP (Interaction to Next Paint)~200ms~150ms< 200ms✅ PASS

Technical Improvements:

  • Async CSS (300-400ms savings)
  • Responsive images (60 KiB savings)
  • Hero eager loading (~2s LCP improvement)
  • Font preloading (CLS fix: 0.602 → <0.1)
  • Database cleanup (58.7% autoload reduction)
  • WordPress memory optimization

Lessons Learned: Technical SEO for Remote Developers

As a digital nomad developer serving European clients, here are the key takeaways from this optimization project:

1. You Don’t Need a Physical Location for Technical SEO

  • I work from Jakarta/Bangkok serving Dutch, German, French, and Belgian clients
  • Technical SEO (speed, schema, Core Web Vitals) is location-independent
  • Remote service delivery works with proper structured data

2. PageSpeed Tools Find the Real Issues

Every optimization in this post came directly from PageSpeed Insights:

  • Render-blocking CSS → Async loading
  • Oversized images → Responsive images
  • LCP delay → Eager loading + fetchpriority
  • CLS from fonts → Font preloading

3. WordPress Memory Limits Are a Hidden Trap

  • Bedrock doesn’t set WP_MEMORY_LIMIT by default
  • WordPress caps itself at 40MB even if PHP allows 1GB
  • Must be explicitly configured in application.php

4. Database Cleanup Matters

  • 365 KB of orphaned plugin data = memory pressure on every request
  • Old WPML/plugin data can linger for years
  • Quarterly database audits should be standard practice

Next Steps: Complete Technical SEO Strategy

This post covered speed optimization and Core Web Vitals. Here’s what’s next for imagewize.com:

  • Article Schema: Add structured data to 310 blog posts
  • Service Schema: Implement on WordPress Dev, WooCommerce, Speed Optimization pages
  • Person Schema: Add founder information (Jasper Frumau as technical expert)
  • Ongoing Monitoring: Track Core Web Vitals, schema validation, performance trends

Want Help Optimizing Your WordPress Site?

I implement these exact strategies for European SMEs and startups. If you’re experiencing:

  • PageSpeed scores below 90
  • Core Web Vitals failures
  • Slow admin panel or WooCommerce pages
  • Missing or incorrect schema markup
  • High bounce rates from slow loading

Get a free technical SEO audit → Contact Us

Conclusion

WordPress speed optimization isn’t about installing a caching plugin and hoping for the best. It’s about:

  1. Measuring – Use PageSpeed Insights to find actual bottlenecks
  2. Fixing – Implement code-level solutions (not just plugin checkboxes)
  3. Verifying – Test with real tools and monitor Core Web Vitals

The six optimizations in this post took imagewize.com from 73 to 99 desktop PageSpeed and 95 mobile:

  1. Async CSS loading (300-400ms savings)
  2. Responsive hero images (60 KiB savings)
  3. Hero eager loading + fetchpriority (~2s LCP improvement)
  4. Font preloading (CLS: 0.602 → <0.1)
  5. Database cleanup (58.7% autoload reduction)
  6. WordPress memory limits (256M/512M)

Total effort: ~10-12 hours over November 2025
Total cost: $0 (just time and technical knowledge)

I’m tracking imagewize.com’s technical SEO performance over the next 6 months. Follow the Speed Optimization category for progress updates on rankings, traffic, and conversions.

Transparency Note

All code examples in this post are from imagewize.com’s actual implementation in November 2025. You can see the results yourself:

Leave a Reply