All WordPress HTML Templates Forms & Webhooks AI & Tools
WordPress

Image Optimisation in WordPress: Formats, Sizes and Lazy Loading

A practitioner’s guide to WordPress image optimization in 2026: AVIF and WebP output, cutting unused sizes, fixing the sizes attribute and lazy loading

Last month we audited a WooCommerce site that was doing everything “right” according to the plugin dashboards. Green ticks across the board. The media library was 4.2 GB, every upload generated eleven derivative files, and the hero on the homepage was a 3.4 MB PNG being scaled down to 1200px wide by CSS. LCP on 4G: 6.1 seconds.

Nobody had done anything stupid. The theme registered its own sizes, a page builder added two more, an optimisation plugin was compressing all of them dutifully, and the CDN was caching the results. Every layer worked. The system as a whole was terrible.

That’s the shape of most WordPress image optimization problems in 2026. Not a missing plugin, but a stack of defaults nobody has ever questioned, compounding. This is what we actually check, in what order, and where the standard advice falls apart.

Key Takeaways

  • WordPress 6.5 and later can produce AVIF, and 6.7 accepts HEIC uploads, but neither is enabled as an output format by default. You turn it on with the imageeditoroutput_format filter, not a plugin.
  • The single most common bug we find is a wrong sizes attribute. WordPress emits (max-width: 1024px) 100vw, 1024px regardless of your actual container, so browsers download a 2048px file for a 700px slot.
  • Core has lazy-loaded images since 5.5 and adds fetchpriority="high" to the first large in-content image since 6.3. If your hero lives in a template part rather than post content, that heuristic picks the wrong image and costs you roughly a second of LCP.
  • Deleting unused registered sizes cuts upload time and disk usage, but strips entries out of your srcset. Remove medium_large, 1536x1536 and 2048x2048 only after checking what your theme actually calls.
  • Quality 82 is the WordPress JPEG default. For WebP, 72 to 78 is usually indistinguishable, and for AVIF the equivalent lands around 50 to 58 because the quality scales are not comparable.

What core already does, and where it guesses wrong

Before you install anything, know what you already have. WordPress has been doing more of this work each release, and half the plugins on the market exist to solve problems core fixed years ago.

  • 5.3: uploads wider than 2560px get scaled down and the original is kept as -scaled. The threshold is filterable via bigimagesize_threshold.
  • 5.5: loading="lazy" added automatically to images with width and height attributes.
  • 5.8: WebP can be generated and served; imageeditoroutput_format arrives.
  • 6.3: fetchpriority="high" applied automatically to the likely LCP image, plus the wpgetloadingoptimizationattributes filter.
  • 6.5: AVIF support in the image editors (needs Imagick with AVIF, or GD 8.1+ compiled with libavif).
  • 6.7: HEIC uploads converted on the fly, and sizes="auto" emitted for lazy-loaded images.

That last one matters more than it sounds. sizes="auto" lets the browser use the image’s actual layout width to pick from the srcset, which fixes a decade-old class of bug, but only for images that are lazy-loaded. Your hero, the one image where getting it wrong is most expensive, is eager by definition and still needs a hand-written sizes.

The guessing goes wrong in two predictable places: the priority heuristic, and any image that comes from a block template, a widget or a builder rather than the main content. We’ll fix both.

Formats: AVIF, WebP, and when JPEG is still correct

Ordering by typical file size at visually equivalent quality: AVIF, then WebP, then JPEG. Google’s own WebP figures put it around 25 to 34 percent under comparable JPEG. AVIF typically lands another 20 percent or so below WebP on photographic content. Browser support for both is universal now across Chrome, Firefox, Safari and Edge, so the fallback question is mostly historical.

Mostly. Two real caveats. AVIF encoding is slow: on shared hosting, generating six AVIF sub-sizes for a 12 megapixel upload can push a request past the PHP timeout, and you’ll get a half-populated media item. AVIF is also worse than WebP on small flat graphics with hard edges, where lossless WebP or even a well-crushed PNG wins.

Here’s the config we ship on most builds. Generate WebP sub-sizes in PHP, keep the original untouched, and let the CDN handle AVIF negotiation where one exists.

// Sub-sizes become WebP. The uploaded original stays in its native format.
addfilter( 'imageeditoroutputformat', function ( $formats ) {
    $formats['image/jpeg'] = 'image/webp';
    $formats['image/png']  = 'image/webp';
    return $formats;
} );

// WordPress defaults to 82 for everything. That's generous for WebP
// and meaningless for AVIF, whose quality scale is not comparable.
addfilter( 'wpeditorsetquality', function ( $quality, $mime_type ) {
    switch ( $mime_type ) {
        case 'image/webp': return 75;
        case 'image/avif': return 55;
        case 'image/png':  return 90; // PNG "quality" maps to compression effort
        default:           return 82;
    }
}, 10, 2 );

Set that filter before a client uploads 800 products, not after. It only applies at generation time, so retrofitting means a full regeneration run (WP-CLI: wp media regenerate --only-missing=0), which on a large library is an hour of CPU you’d rather not spend.

Transparency and animation

PNG with alpha converts to WebP cleanly and usually halves in size. Animated GIFs should not be images at all. Convert them to muted, looping, playsinline MP4 or WebM and you’ll typically go from 4 MB to under 300 KB. No plugin does this well; do it once at build time with ffmpeg.

Stop generating twelve versions of everything

A stock install registers thumbnail, medium, medium_large, large, 1536x1536 and 2048x2048. Your theme adds three or four. A slider plugin adds two. Upload one photo, write eleven files plus the scaled original.

Audit before you cut. Drop this in a snippet, load any admin page, and read the log:

addaction( 'admininit', function () {
    if ( ! currentusercan( 'manage_options' ) ) {
        return;
    }
    // Registered sizes, including everything themes and plugins added.
    errorlog( printr( wpgetregisteredimagesubsizes(), true ) );
} );

Then grep the theme for thepostthumbnail( and wpgetattachment_image( to see which named sizes are ever requested. Whatever survives, keep. The rest:

addfilter( 'intermediateimagesizesadvanced', function ( $sizes ) {
    unset( $sizes['medium_large'], $sizes['1536x1536'] );
    return $sizes;
} );

// 2560 is more than most layouts ever need. 2048 is plenty for a full-bleed
// hero on a 2x display at typical viewport widths.
addfilter( 'bigimagesizethreshold', function () {
    return 2048;
} );

The trade-off is real: every size you remove is one fewer candidate in the srcset, so the browser’s choice gets coarser and it may download a file 400px wider than it needs. Our rule of thumb is to keep sizes roughly 1.4x apart in width and never leave a gap larger than 2x. Four to six well-spaced widths beat eleven clustered ones.

The sizes attribute is the bug nobody looks at

This one is worth more than the format change on most sites, and almost nobody checks it.

WordPress computes sizes as (max-width: {W}px) 100vw, {W}px, where W is the width of the chosen size. For an image inserted at large (1024px) sitting in a 700px content column on a 2x laptop, the browser reads that as “this is 1024 CSS pixels wide”, multiplies by DPR, and fetches the 2048 file. You pay for four times the pixels you display.

Open DevTools, hover the image node, and compare “Intrinsic size” to “Rendered size” in the tooltip. If intrinsic is more than about 2.2x rendered on a 2x screen, you’ve found it. It’s the same diagnostic muscle as debugging a CSS layout problem: measure what the browser actually did, don’t trust what the template intended.

addfilter( 'wpcalculateimagesizes', function ( $sizes, $size, $src, $meta, $attachment_id ) {
    // Single posts: content column is 720px, full-bleed below that.
    if ( is_singular( 'post' ) ) {
        return '(max-width: 782px) 100vw, 720px';
    }
    // Archive cards: 3 across on desktop, 2 on tablet, 1 on mobile.
    if ( isarchive() || ishome() ) {
        return '(max-width: 600px) 100vw, (max-width: 1024px) 45vw, 30vw';
    }
    return $sizes;
}, 10, 5 );

Yes, this hardcodes layout knowledge into PHP, and yes, that’s fragile. It’s still the correct trade. A wrong sizes silently doubles your image bytes forever; a stale sizes after a redesign costs you fifteen minutes. On sites where images are rendered through a consistent set of reusable block patterns, you can go further and set sizes per pattern, which keeps the value next to the markup that defines the container.

How to lazy load images without wrecking LCP

Lazy loading below the fold is free performance. Lazy loading your hero is one of the most expensive single mistakes you can make, because loading="lazy" defers the request until layout is computed, adding 300 to 900 ms of resource load delay to your LCP on a mid-range Android.

Core protects against this with a threshold: the first N images on the page skip lazy loading. The default is small, and it counts images in the main content loop. Your hero, sitting in header.php or a block template part, often isn’t counted at all, so it gets lazy-loaded while three thumbnails further down the page get eager treatment.

Stop relying on the heuristic for the one image that matters. Render it explicitly:

$heroid = getpostthumbnailid();

echo wpgetattachmentimage( $heroid, 'full', false, [
    'class'         => 'hero-image',
    'sizes'         => '100vw',
    'loading'       => 'eager',        // never defer the LCP candidate
    'fetchpriority' => 'high',         // jump the queue ahead of CSS-discovered assets
    'decoding'      => 'sync',         // avoid a paint-after-decode gap on the LCP element
    'alt'           => getthetitle(),
] );

And nudge the threshold so a couple of above-the-fold images stay eager:

addfilter( 'wpomitloadingattr_threshold', function () {
    return 3; // first three images render eagerly; tune to your fold
} );

Only one image on a page should carry fetchpriority="high". Two competing high-priority images is the same as none, and we’ve seen builder plugins add it to every slide in a carousel. Check the rendered HTML, not the plugin settings.

Background images are the blind spot

A CSS background-image gets no srcset, no lazy loading and no fetchpriority. It’s discovered late, after CSS parses. If a full-bleed section is your LCP element, either convert it to an <img> with object-fit: cover, or preload it with a matching imagesrcset:

<link rel="preload" as="image"
      href="/wp-content/uploads/2026/01/hero-1600.webp"
      imagesrcset="/wp-content/uploads/2026/01/hero-800.webp 800w,
                   /wp-content/uploads/2026/01/hero-1600.webp 1600w,
                   /wp-content/uploads/2026/01/hero-2048.webp 2048w"
      imagesizes="100vw" fetchpriority="high">

Prefer the <img> route. Preload tags rot the moment someone changes the hero in the admin.

CDN transformation or a compression plugin

If the site sits behind Cloudflare, Bunny, Fastly or a comparable edge with image transformation, do format conversion and resizing there and stop generating multiple formats in PHP. The edge negotiates on the Accept header, so Safari gets WebP, Chrome gets AVIF, and your origin stores one file per size. Uploads get faster, your backups shrink, and a format change is a config toggle instead of a regeneration job.

Use a compression plugin (ShortPixel, Imagify, EWWW) when there’s no transforming CDN, when the client is on cheap shared hosting, or when you need the media library itself to hold optimised files because a third party reads from it. What you should not do is run both. We’ve inherited sites re-encoding an already-lossy WebP at the edge, adding artefacts and CPU for zero byte savings.

Whichever you pick, images are one input into the Core Web Vitals picture, not the whole thing. If TTFB is 800 ms because object caching is off, a perfect AVIF pipeline moves LCP by a rounding error. Our WordPress performance playbook covers the ordering of those fixes; image work belongs after server response time is under control, not before.

The checklist we actually run

  1. Measure first. Chrome DevTools performance panel, LCP subparts. Note whether the time is in TTFB, resource load delay or load duration. Only load duration is an image-size problem.
  2. Find the LCP element on the three highest-traffic templates. Give exactly one of them fetchpriority="high" and loading="eager".
  3. Compare intrinsic versus rendered size on ten images. Fix sizes where the ratio exceeds roughly 2.2x on a 2x display.
  4. Audit registered sizes, delete the unused, set the big image threshold to 2048.
  5. Set the output format and per-format quality, then regenerate with WP-CLI during a quiet window.
  6. Confirm width and height attributes are present everywhere, so CLS stays near zero.
  7. Re-measure on the same connection profile. If nothing moved, the bottleneck was never images.

Steps 2 and 3 usually account for most of the improvement. Format conversion, the thing every listicle leads with, is often fourth in impact order.

Frequently Asked Questions

Should I use WebP or AVIF for WordPress in 2026?

Use WebP as your baseline output format and serve AVIF through a CDN that negotiates on the Accept header. Browser support is not the issue anymore; encoding cost is. Generating six AVIF sub-sizes per upload on shared hosting can time out PHP and leave you with broken media items, whereas WebP encodes fast enough to be safe everywhere.

Does WordPress lazy load images by default?

Yes, since WordPress 5.5, any image with width and height attributes gets loading="lazy" automatically, and since 6.7 lazy-loaded images also get sizes="auto". Core skips the first few images to protect LCP, but that heuristic counts in-content images and frequently misses heroes rendered from template parts. Check your rendered HTML rather than assuming.

Will deleting image sizes break existing posts?

Existing files stay on disk, so anything already referencing them keeps working. New uploads simply won’t generate the removed sizes, which makes the srcset sparser for that image. If you also delete old files with a cleanup tool, check first that no hardcoded template call requests a size you removed, or you’ll get full-size originals served in thumbnail slots.

What JPEG quality should I set in WordPress?

Leave JPEG at the 82 default and lower WebP to around 75 and AVIF to around 55, because the three encoders use unrelated quality scales. Below WebP 65 you’ll start seeing banding in gradients and skies. Test on a photo with a large flat sky area, since that’s where compression artefacts show first.

Do I still need an image optimisation plugin if I use a CDN?

No, and running both usually hurts. A transforming CDN resizes and converts on the fly from a single origin file, so plugin-side compression just re-encodes already-lossy output and adds artefacts. Keep the plugin only if you have no transforming edge, or if another system reads optimised files directly from the media library.

Pick one page tomorrow, ideally your highest-traffic template. Open DevTools, find the LCP element, and check two things: whether it’s lazy-loaded, and whether its intrinsic width is more than twice its rendered width. Those two checks take four minutes and account for most of the wins we ship on client sites. Everything else in this article is refinement on top.

big_image_size_threshold wordpress disable unused wordpress image sizes fetchpriority high wordpress hero image image_editor_output_format avif wordpress lazy load images wordpress lcp webp wordpress output format filter wordpress image optimization wp_calculate_image_sizes srcset fix