Your phone loads a 4MB JPEG on a 3G connection while the desktop browser gets a 180KB WebP. The HTML looks correct: an <img> with a src, a srcset, and a sizes attribute. The problem is not the markup. The problem is that wp_calculate_image_srcset() returned false, WordPress emitted the full-size URL as the sole src, and the browser had no candidates to choose from. This article traces that failure from the rendered tag back to _wp_attachment_metadata, through the core hooks that decide what goes into srcset, and ends with a verification procedure that does not require regenerating every thumbnail on the site.
Symptom
Open DevTools, switch to a mobile viewport, and inspect the image request. You see one of these patterns:
- The
<img>has asrcpointing at the original upload (for example,2026/09/hero.jpg) and nosrcsetattribute at all. - The
<img>has asrcsetwith a single candidate, usually the full-size file, and asizesattribute that claims the image will render at 100vw. - The
srcsetexists but contains only thethumbnailandmediumcandidates, so the browser picks the largest available and upscales it on a 1440px viewport.
In all three cases, the network panel shows the mobile device downloading a file that is several megabytes larger than necessary. The desktop browser may look fine because it has a wider viewport and the browser’s own heuristics pick a different candidate, or because the desktop cache already holds the original.
Trace
Start at the function that builds the attribute. wp_calculate_image_srcset() is documented as returning string|false, where false means an error or that only one source exists. The source code shows the early exits:
if ( empty( $image_meta['sizes'] ) || ! isset( $image_meta['file'] ) || strlen( $image_meta['file'] ) < 4 ) {
return false;
}
That is the first place to look. If _wp_attachment_metadata has no sizes key, or the key is an empty array, the function bails before it ever inspects the requested dimensions. The wp_get_attachment_metadata() reference confirms the expected shape: sizes is an array keyed by size slug, and each value contains file, width, height, and mime-type. If that array is missing or malformed, srcset cannot be built.
If sizes is present, the function loops through each entry and applies several filters. The relevant ones for this failure are:
wp_calculate_image_srcset_meta— runs before the early exit, so a plugin can repair$image_metahere.max_srcset_image_width— defaults to 2048, and excludes any candidate wider than that unless it is thesrcimage.wp_calculate_image_srcset— runs after the loop, and can add or remove candidates.
The loop also checks wp_image_matches_ratio(). Only candidates whose aspect ratio matches the requested size within one pixel are included. A cropped intermediate size with a different ratio is silently skipped. That is documented behavior, not a bug: srcset is for resolution switching, not art direction.
After the loop, the function checks $src_matched. If no candidate’s filename appears inside the src URL, the function returns false to avoid serving an incorrect image. This is the second common failure point. If the src points at a file that is not represented in sizes — for example, because the original was replaced or the metadata was written by a different process — the match fails and srcset is suppressed.
Finally, the function returns false if fewer than two sources remain after filtering. A single candidate is not a srcset; it is just a src with extra steps.
When wp_calculate_image_srcset() returns false, wp_get_attachment_image_srcset() returns false, and wp_img_tag_add_srcset_and_sizes_attr() does not add the attributes. The <img> keeps whatever src the block or theme supplied. In the block editor, that is often the full-size URL when the image block’s sizeSlug is unset or set to full.
Root cause
The root cause is almost always an incomplete sizes array in _wp_attachment_metadata. The array is written by wp_generate_attachment_metadata() during upload, and updated by wp_create_image_subsizes() and wp_update_image_subsizes(). If any of those steps fail, or if the registered sizes change after upload, the stored metadata no longer reflects the files on disk.
Three failure modes produce this state:
- The upload request timed out before sub-sizes were generated. The original file was written, the attachment post was created, but
wp_generate_attachment_metadata()did not finish. Thesizeskey is missing or partial. - The image editor could not process the file.
WP_Image_Editoris abstract; the concrete implementation isWP_Image_Editor_GDorWP_Image_Editor_Imagick. If neither supports the mime type, or if the server lacks memory,multi_resize()returns aWP_Errorand the sub-sizes are not recorded. - The registered sizes changed after upload. A theme or plugin registered a new size, or the
medium_size_woption was changed. Existing attachments do not automatically get the new size.wp_get_missing_image_subsizes()exists to compare stored metadata against currently registered sizes, but it only runs when something calls it — typically the admin media screen or a WP-CLI command.
There is also a subtler case: the sizes array is present and populated, but the src URL does not match any entry. This happens when the block editor stores a sizeSlug that no longer exists, or when the image was edited in WordPress and the metadata contains a hash suffix that the src does not include. The wp_calculate_image_srcset() source checks for the edit hash and filters out candidates from previous edits. If the src is from an older edit, no candidate matches and the function returns false.
Fix
Do not start by regenerating thumbnails. Start by reading the metadata. The following WP-CLI command prints the sizes array for a given attachment ID:
wp post meta get 1234 _wp_attachment_metadata --format=json | jq '.sizes'
If jq is not available, use wp eval:
wp eval '
$meta = wp_get_attachment_metadata( 1234 );
if ( empty( $meta["sizes"] ) ) {
echo "sizes is empty or missing\n";
} else {
foreach ( $meta["sizes"] as $slug => $data ) {
printf( "%s: %dx%d (%s)\n", $slug, $data["width"], $data["height"], $data["file"] );
}
}
'
Compare that output to the currently registered sizes:
wp eval 'print_r( wp_get_registered_image_subsizes() );'
wp_get_registered_image_subsizes() returns a normalized list of all currently registered sub-sizes, keyed by name. If a size is registered but absent from the attachment metadata, the file may or may not exist on disk. Check the uploads directory directly:
wp eval '
$meta = wp_get_attachment_metadata( 1234 );
$dir = wp_get_upload_dir();
$path = trailingslashit( $dir["basedir"] ) . dirname( $meta["file"] );
foreach ( wp_get_registered_image_subsizes() as $slug => $size ) {
if ( empty( $meta["sizes"][ $slug ] ) ) {
echo "missing metadata: $slug\n";
}
}
'
If the metadata is missing entries but the files exist, the fix is to update the metadata without re-encoding. wp_update_image_subsizes() is the core function for this, but it is defined in wp-admin/includes/image.php, so it is not loaded on the front end. A WP-CLI command can load it:
wp eval '
require_once ABSPATH . "wp-admin/includes/image.php";
$updated = wp_update_image_subsizes( 1234 );
if ( is_wp_error( $updated ) ) {
echo $updated->get_error_message() . "\n";
} else {
echo "updated\n";
}
'
If the files do not exist, wp_update_image_subsizes() will attempt to create them. That is the correct behavior, but it requires a working image editor and sufficient memory. If the server cannot create the files, the metadata will remain incomplete and srcset will continue to fail.
For a diagnostic view without modifying anything, use the wp_calculate_image_srcset_meta filter. It runs before the early exit and receives the raw $image_meta array. A temporary mu-plugin can log the array for a specific attachment:
add_filter( 'wp_calculate_image_srcset_meta', function( $image_meta, $size_array, $image_src, $attachment_id ) {
if ( 1234 === $attachment_id ) {
error_log( print_r( $image_meta, true ) );
}
return $image_meta;
}, 10, 4 );
That filter is also the correct place to repair inconsistencies in stored data, as the documentation states. If you need to add a candidate that is not in sizes, use wp_calculate_image_srcset instead. It receives the final $sources array and can append or remove entries. The hook reference includes an example that adds a custom header image source.
Do not use intermediate_image_sizes_advanced to fix this. That filter runs during upload and changes which sizes are generated. It does not repair existing metadata, and it does not affect wp_calculate_image_srcset() at render time.
Verification
After updating the metadata, verify the rendered output. The most direct check is to fetch the attachment’s srcset value:
wp eval '
$srcset = wp_get_attachment_image_srcset( 1234, "large" );
var_dump( $srcset );
'
A non-false string means the function found at least two matching candidates. If it returns false, the metadata is still incomplete or the src does not match any entry.
Next, inspect the actual HTML. Use curl with a mobile user agent, or open the page in a browser and view source. Look for the srcset attribute and confirm it contains multiple widths. The sizes attribute should also be present. If sizes is missing, the browser defaults to 100vw, which is often wrong for a content column. That default is not a WordPress bug; it is the HTML specification’s behavior when sizes is absent.
Finally, check the network request in DevTools. With a mobile viewport, the browser should request an intermediate size, not the original. If it still requests the original, check whether the src attribute itself points at the full-size file. The src is the fallback for browsers that do not support srcset, and it is also the image the browser uses if no candidate matches. In the block editor, the image block’s sizeSlug attribute controls which size is used for src. If it is set to full, the src will be the original even when srcset is present. The browser may still choose a smaller candidate from srcset, but the fallback is the large file.
To confirm the block editor’s stored value, inspect the post content:
wp post get 5678 --field=content | grep -o 'sizeSlug[^}]*'
If the block has no sizeSlug, WordPress uses the default. The default is not a fixed size name; it depends on the block’s attributes and the theme’s settings. In practice, an image block without an explicit size often falls back to full for the src, while srcset still offers smaller candidates. That is acceptable for modern browsers, but it means the src is not a small file. If you need the src itself to be an intermediate size, set the block’s size explicitly in the editor or filter the rendered block.
What not to do
Do not install a plugin that rewrites srcset before you have confirmed the metadata is complete. The core function is not broken; it is returning false because the data it needs is missing. A filter that forces a srcset string can produce candidates that do not exist on disk, which results in broken images on some viewports.
Do not regenerate all thumbnails as a first step. On a large library, that is a long-running operation that may time out and leave the site in a worse state. Use wp_get_missing_image_subsizes() to identify which attachments actually need work, and process them in batches.
Do not assume the problem is the sizes attribute. The sizes attribute tells the browser how wide the image will be displayed. It does not create candidates. If srcset is missing, sizes has nothing to select from.
FAQ
Why does the desktop browser load a small image but mobile loads the original?
Desktop browsers often have a wider viewport and a larger device pixel ratio, so they may select a larger candidate from srcset. If srcset is missing entirely, both desktop and mobile load the src. The difference you see may be caching: the desktop browser already has an intermediate size cached from a previous request, while mobile is requesting the src for the first time. Check the actual src and srcset attributes in the HTML, not just the network panel.
Does wp_calculate_image_srcset() include the full-size image?
Yes, unless the image is an animated GIF. The source code appends the full-size dimensions to the $image_sizes array before the loop, unless the thumbnail’s mime type is image/gif. For GIFs, the behavior differs to avoid hiding animation. For JPEG, PNG, and WebP, the full-size image is a candidate, but it is subject to the max_srcset_image_width filter, which defaults to 2048 pixels. If the original is wider than 2048 and is not the src image, it is excluded from srcset.
Can I fix this without WP-CLI?
Yes. The same functions are available in the admin. The media library’s edit screen may offer a regenerate option depending on your setup, but the core function wp_update_image_subsizes() is not exposed as a button by default. A small admin-side script or a one-time wp eval command is usually faster than clicking through the media library. If you prefer a plugin, choose one that calls the core functions rather than reimplementing them.
What if the metadata is correct but srcset is still missing?
Check the src URL against the file values in sizes. The function requires a match. If the src points at a CDN URL or a resized version that is not in the metadata, the match fails. The wp_calculate_image_srcset_meta filter can be used to normalize the metadata, or the wp_calculate_image_srcset filter can be used to add the missing source. Both are documented hooks, and both are preferable to filtering the final HTML.
Does the REST API affect this?
The REST API’s attachments controller uses wp_get_attachment_metadata() and wp_get_registered_image_subsizes() when preparing responses and validating image dimensions. If the metadata is incomplete, the REST response may omit size information, and the block editor may not offer the missing sizes in the image block’s size dropdown. That is a symptom, not a cause. Fixing the metadata fixes the REST response.
What about WP-Cron?
WP-Cron does not generate image sub-sizes by default. It may be used by plugins to schedule background processing, but core’s upload flow generates sub-sizes synchronously during the upload request. If the upload request times out, WP-Cron is not a fallback. The wp_generate_attachment_metadata() function runs in the same request that handles the upload. If that request is killed, the metadata is incomplete and no scheduled event will repair it unless a plugin explicitly schedules one.
For a related failure mode where a new site returns no results because the query is looking for content that was never published, see What to Fix First When a New WordPress Site Says Nothing Found. The diagnostic pattern is similar: read the stored data before changing the rendering layer.