Most WordPress image problems are ordering problems. A plugin gets installed for WebP, then the theme's image sizes change, then someone notices half the media library is still JPEG and regenerates twice. Or the sizes attribute gets set in a template and an HTML minifier on the CDN strips it an hour later.
The configuration has a dependency order. Files on disk come first, the markup that points at them comes second, and the layer that rewrites the HTML comes last. Here is the chain, plus the parameters that usually stay at whatever the default was.
| # | Step | Depends on |
|---|---|---|
| 1 | Decide rendered widths | layout |
| 2 | Register only those sizes | 1 |
| 3 | Set output format, generate files | 1, 2 |
| 4 | Set the large-image threshold | 3 |
| 5 | Correct sizes per rendered context |
2 |
| 6 | Lazy loading and fetchpriority on the LCP image |
markup |
| 7 | srcset max width cap | 2 |
| 8 | Verify the delivered markup | 3-7 |
| 9 | Check anything that rewrites HTML | 8 |
Steps 2-4 create files. Steps 5-7 decide which file the browser picks. Do 5 before 2 and your sizes values point at candidates that were never generated.
1-2. Register the sizes you actually render
Intermediate sizes are created at upload time, and srcset is built from whatever exists for that attachment. Extra sizes cost disk, upload time and one more candidate for the browser to weigh. Missing sizes mean the browser gets a file that is too small, or one that is far too large.
<?php
// wp-content/mu-plugins/image-delivery.php
// mu-plugins load automatically; there is no activation step.
add_action( 'after_setup_theme', function () {
// Hard crop (true) = exactly these pixels. Use it for cards and heroes
// where the layout expects a fixed ratio.
add_image_size( 'card-480', 480, 320, true );
add_image_size( 'card-960', 960, 640, true );
add_image_size( 'hero-1600', 1600, 900, true );
} );
// Drop core sizes this theme never renders.
add_filter( 'intermediate_image_sizes_advanced', function ( $sizes ) {
unset( $sizes['medium_large'] );
return $sizes;
} );
true as the fourth argument is a hard crop: the file is exactly that size, so the layout does not shift when the image loads. false crops proportionally and can produce a height you did not ask for. That is fine for editorial images and wrong for a card grid.
Registered sizes only apply to new uploads. Existing attachments need regeneration, which is step 3.
3. Output format first, then generate the files
Core maps JPEG uploads to WebP when the server's image editor can write WebP. The mapping is filterable and it is applied when sub-sizes are generated, so changing it does nothing to files that already exist.
Confirm the server can do it:
wp eval 'var_dump( wp_image_editor_supports( array( "mime_type" => "image/webp" ) ) );'
Then the mapping:
add_filter( 'image_editor_output_format', function ( $formats ) {
$formats['image/jpeg'] = 'image/webp';
// PNG is left alone on purpose. Check what happens to transparent
// logos and UI screenshots before you change that line.
return $formats;
} );
Then rebuild. On a large site, run it on a copy first: it is CPU-heavy and it rewrites files in place.
wp media regenerate --only-missing --yes
--only-missing skips attachments that already have every registered size. Drop it when you have changed the output format and need the files rebuilt anyway. Verify one attachment afterwards:
wp media image-size 123
Do not delete the originals. The full-size file is what the largest srcset candidate and any future size will be generated from.
4. The large-image threshold
Uploads wider than big_image_size_threshold (2048 px by default) are kept as-is and the "full" size becomes a -scaled copy. That is usually what you want: a 6000 px phone photo should not be the file the browser downloads.
add_filter( 'big_image_size_threshold', function ( $threshold ) {
return 2048; // return false to disable scaling entirely
} );
The parameter people miss: the threshold is applied at upload. Lowering it later does not re-scale anything already in the library.
5. sizes - the parameter almost everyone leaves at the default
srcset offers candidates. sizes tells the browser how wide the slot will be, and the browser picks a candidate from that. Core derives the default from the image's own width:
(max-width: 1600px) 100vw, 1600px
For a 1600 px hero sitting in a 760 px column, that default claims the image fills the viewport. The browser believes it and pulls the 1600 px file for a 760 px slot.
Fix it per rendered context. Filtering on the size name keeps it from leaking into every image on the site:
add_filter( 'wp_get_attachment_image_attributes', function ( $attr, $attachment, $size ) {
if ( 'card-960' === $size ) {
// Three-column grid; a column never exceeds 380 CSS px.
$attr['sizes'] = '(max-width: 600px) 100vw, (max-width: 960px) 50vw, 380px';
}
if ( 'hero-1600' === $size ) {
$attr['sizes'] = '100vw';
}
return $attr;
}, 10, 3 );
Two things that go wrong here:
-
The value has to match the CSS. If the grid collapses to one column at 900 px and your
sizesstill says50vwup to 960 px, the browser picks a file that is too small and the image looks soft on a phone. -
Raw
<img>tags get nothing.srcset,sizesand the lazy attribute are added bywp_get_attachment_image()and by the content filter. A template that prints<img src="...">from a custom field bypasses all of it. Render attachment images through the API, or build the attributes yourself.
6. Lazy loading, and the one image that must not be lazy
Core adds loading="lazy" to attachment images and to images in post content. That is correct for everything below the fold and wrong for the hero: deferring the largest-contentful image pushes it later and hurts the metric you were trying to protect.
function mytheme_hero_image( $attachment_id ) {
echo wp_get_attachment_image(
$attachment_id,
'hero-1600',
false,
array(
'class' => 'hero__img',
'loading' => 'eager',
'fetchpriority' => 'high',
'decoding' => 'sync',
)
);
}
Passing loading explicitly overrides the default instead of fighting it with a filter that has to guess which image is the hero.
If you would rather keep the markup in the template and correct it afterwards: wp_lazy_loading_enabled receives the tag name and a context string, and wp_img_tag_add_loading_attr receives the computed value plus the full tag. That is enough to target the first image in a known context, and not enough to reliably identify "the hero" across several templates. Explicit attributes are easier to audit later.
7. The srcset cap
Core caps candidates with max_srcset_image_width, 2048 by default. If the layout asks for a 1600 CSS px hero on a high-density display, the candidate you want is 3200 px wide and it will not appear in srcset until the cap moves.
add_filter( 'max_srcset_image_width', function ( $max ) {
return 3200;
} );
Raise it only if that size actually exists (step 2) and the file is compressed. Raising the cap without the sizes to back it up changes nothing.
8. Verify what the browser actually received
View source, find an <img>, and check three things: srcset has several candidates, sizes describes your layout rather than the image width, and the hero has no loading="lazy".
Then the network panel: filter to images, load the page at a narrow viewport, then at a wide one, and confirm a different candidate is chosen each time. Confirm the response Content-Type is image/webp for converted files and not image/jpeg.
A quick look from the command line:
curl -s https://example.com/ | grep -o '<img[^>]*>' | head -5
9. The layer that undoes all of it
If a CDN, an optimization plugin or a minifier rewrites HTML after WordPress renders it, it can drop srcset, move sizes, or replace src with a placeholder and load the real file from JavaScript. Inspect the final HTML in the browser, not the theme's output. If something downstream is stripping attributes, fix that before adding more image configuration, otherwise you are tuning values that never reach the browser.
Checklist
- [ ] Rendered widths decided per template, before the first upload
- [ ]
add_image_size()for each of them, hard crop where the ratio is fixed - [ ] Unused core sizes removed via
intermediate_image_sizes_advanced - [ ] WebP support confirmed with
wp_image_editor_supports() - [ ] Output format filtered, then
wp media regeneraterun - [ ]
big_image_size_thresholdset to what you actually serve - [ ]
sizesset per context, matching the CSS breakpoints - [ ] Hero:
loading="eager"plusfetchpriority="high" - [ ] Everything else lazy, and confirmed lazy in the network panel
- [ ]
max_srcset_image_widthchecked against the largest size that exists - [ ] Final HTML inspected after any rewriter has run
If you want this checklist run against a live site instead of a fresh install, that is what the site check is for.
Top comments (0)