Image optimisation is a build step, not a plugin

The homepage scored 34 on mobile and the largest contentful paint was 6.1 seconds, of which 4.4 was one image. An optimisation plugin had been installed in 2019 and was working exactly as designed — it processes images on upload, and the hero had been uploaded in 2018.

The symptom

$ curl -sI https://site.example/wp-content/uploads/2018/04/hero.jpg 
    | grep -i content-length
content-length: 4402118

$ identify -format '%wx%h %[colorspace] %Qn' hero.jpg
4000x2250 sRGB 98

# displayed at 1200px wide. quality 98. no WebP.
# and 41 other images uploaded before the plugin existed.

$ find wp-content/uploads -name '*.jpg' -size +500k | wc -l
412

Four hundred and twelve images over half a megabyte, none of which the plugin has ever touched, because it hooks the upload and nothing walks what already exists. The plugin’s dashboard reported ninety-four per cent optimised, counting only the images it had seen.

Why it happens

An upload hook is the natural place to put this and it covers exactly the images uploaded after installation. The existing library, the images added by an import, and anything restored from a backup are all invisible to it.

The fix

A pass over everything, not a hook

set -euo pipefail

find wp-content/uploads -type f ( -name '*.jpg' -o -name '*.png' ) 
  -print0 |
while IFS= read -r -d '' f; do
  webp="${f%.*}.webp"

  # generated once and left alone, so the pass is idempotent
  [ -f "$webp" ] && continue

  cwebp -q 78 -metadata none "$f" -o "$webp" >/dev/null 2>&1
  jpegoptim --strip-all --max=82 "$f" >/dev/null 2>&1 || true
done

Generating a sibling WebP rather than replacing the original is what makes this reversible and what lets the front end choose per request. Skipping files that already have one makes the script idempotent, which is what allows it to run on a schedule rather than being a one-off somebody remembers.

Quality 78 for WebP and 82 for JPEG are the numbers that survived a blind comparison on this site’s photography; a design-heavy site with flat colour tolerates lower and a photography site does not. Choosing them by looking rather than by copying is twenty minutes well spent.

Serving the right format

map $http_accept $webp_suffix {
    default   "";
    "~*webp"  ".webp";
}

location ~* ^(/wp-content/uploads/.+).(jpe?g|png)$ {
    add_header Vary Accept;
    try_files $1$webp_suffix $uri =404;

    expires 1y;
    add_header Cache-Control "public, immutable";
}

The Vary: Accept header is not optional and is the thing that gets forgotten: without it a CDN caches whichever variant it fetched first and serves WebP to a browser that cannot display it. That failure affects a small fraction of visitors and is invisible in any test done on a modern browser.

Doing the negotiation at the web server rather than in markup keeps the HTML identical for every visitor, which matters for page caching. The <picture> element is the alternative and moves the decision into the markup, where a full-page cache has to hold one variant for everybody — which is fine, because it holds both sources.

Responsive sources, and the sizes attribute

<img src="/uploads/2018/04/hero-1200.jpg"
     srcset="/uploads/2018/04/hero-600.jpg   600w,
             /uploads/2018/04/hero-1200.jpg 1200w,
             /uploads/2018/04/hero-2000.jpg 2000w"
     sizes="(max-width: 700px) 100vw, 1200px"
     width="1200" height="675"
     alt="" fetchpriority="high">

<!-- WordPress emits srcset automatically and gets
     `sizes` wrong for almost every layout: it defaults
     to (max-width: Npx) 100vw, Npx -->

The default sizes attribute claims the image is full-width at every viewport, so a browser on a wide screen selects the largest source for an image displayed at four hundred pixels. Fixing it per template is the single largest saving available and it is a filter rather than a plugin.

The width and height attributes are what reserve the space and remove the layout shift, and they came back into fashion after a decade of being stripped as unnecessary. The browser computes an aspect ratio from them before the image arrives.

The image that must not be lazy

// WordPress lazy-loads everything from 5.5, including the
// image that IS the largest contentful paint
add_filter( 'wp_img_tag_add_loading_attr',
    static function ( $value, string $image, string $context ) {
        static $seen = 0;

        if ( 'the_content' === $context && 0 === $seen++ ) {
            return false;      // the first image in the content
        }

        return $value;
    }, 10, 3 );

Lazy-loading the LCP element delays it by a round trip, because the browser cannot start the request until layout tells it the image is near the viewport. That converts a performance feature into a performance regression, and it is on by default.

The static counter is crude and works because the first image in the content is the hero on every template here. A site where that is not true needs the decision per template, which is more filter and less cleverness.

Measuring in the field rather than in a lab

import { getLCP, getCLS, getFID } from 'web-vitals'

const send = ({ name, value, id }) => {
  navigator.sendBeacon('/vitals', JSON.stringify({
    name, value, id,
    path: location.pathname,
    conn: navigator.connection?.effectiveType,
  }))
}

getLCP(send); getCLS(send); getFID(send)

A lab score measures one load on one connection from one location, and the metric that counts is the 75th percentile of real visitors over twenty-eight days. Collecting it per path is what makes it actionable — the homepage and the product template have different problems and an aggregate hides both.

sendBeacon rather than fetch is what makes collection reliable, because it survives the page being closed. Recording the connection type alongside is what stops the numbers being dominated by whoever happens to be on a fast connection that week.

Verifying it worked

$ curl -sI -H 'Accept: image/webp' 
    https://site.example/wp-content/uploads/2018/04/hero.jpg 
    | grep -iE 'content-type|content-length|vary'
content-type: image/webp
content-length: 188402
vary: Accept

# 4.4 MB → 188 KB

$ curl -sI https://site.example/wp-content/uploads/2018/04/hero.jpg 
    | grep -i content-type
content-type: image/jpeg        # the fallback still works

# field data, 28 days later, p75 mobile:
#   LCP  6.1s → 1.9s
#   CLS  0.24 → 0.02

Testing both with and without the WebP accept header is the check that the fallback path works, and it is the one that catches a try_files misconfiguration serving a 404 to older browsers. The field data is the outcome and it takes four weeks to be meaningful, which is worth saying before anybody expects a number the next morning.

What this costs

A pipeline stage and roughly forty per cent more storage, because every image now exists in two formats and several sizes. That is cheap and it is not free, and the sizes multiply if AVIF is added later — which is the obvious next step and doubles the storage again.

The nightly pass is also a process that will silently stop working. A cron job that fails leaves new uploads unoptimised, and nothing about the site looks wrong — which is why the check that matters is not “did the job run” but a scheduled query for images over a size threshold with no WebP sibling, alerting when it is not zero.