View source on almost any WordPress post and the img tags already carry a srcset attribute: five or six URLs, each tagged with a pixel width. Core has done this automatically since 4.4 and nobody configured it.
Now open the network panel and look at which file the browser actually fetched. On a great many sites it is a 1536 pixel wide image dropped into a 720 pixel column, on an ordinary laptop with an ordinary display. The srcset is present, complete and entirely honest. The problem is the attribute sitting next to it.
That attribute is sizes, and WordPress fills it with a guess, because core has no way to see your CSS. Everything worth knowing about WordPress srcset is really about how that guess is made, when it is wrong, and the several places where the whole mechanism silently never runs.
If you have not yet established that image quality is your actual problem, start with the six causes of blurry WordPress images, which is a diagnostic. This article assumes you have narrowed it to responsive images and want the mechanism.
Two different things called a pixel
The confusion underneath all of this is that the word pixel means two things.
A device pixel is a physical dot on the screen. A CSS pixel is a unit of layout, roughly the size a dot used to be on an old monitor, kept as a reference so a 300 pixel wide box stays about the same physical size everywhere.
On an old display these matched. On a modern phone, one CSS pixel is painted with two or three device pixels in each direction. That multiplier is the device pixel ratio, and it is why a photograph placed in a 400 CSS pixel wide slot may need to fill 800 or 1200 real dots. Type window.devicePixelRatio in a browser console to see it for the device you are holding.
Supply 400 pixels of image data for 1200 dots and the browser invents the rest by interpolation. Interpolation is a polite word for guessing, and guessing looks soft. The obvious fix, uploading everything at three times the size, trades one problem for a worse one: the phone with the dense screen is usually the device on the slowest connection, and you just tripled its download.
What you want is several files and a browser that chooses well. That is the job srcset exists to do.
What srcset and sizes each claim
Two attributes, two completely different jobs, and mixing them up is the usual cause of trouble.
srcset is a menu. Here are the versions of this image that exist, and here is how wide each one is in real pixels. It is a statement of fact about your files, and WordPress can make it accurately because it created those files itself.
sizes is a promise. Here is how wide this image will be displayed, at various viewport widths. It is a statement about your layout, and WordPress cannot make it accurately because your layout lives in a stylesheet core never reads.
The browser reads the promise, multiplies by the device pixel ratio, and picks the smallest entry on the menu that meets or exceeds the result. It does this in the preload scanner, before layout has happened and often before the stylesheet has arrived, which is precisely why it has to be told rather than measure for itself. A wrong sizes value is not corrected later. The file is already on its way.
How core builds the srcset attribute
Everything happens in wp-includes/media.php. The function that does the work is wp_calculate_image_srcset(), and the wrapper you would call from a template is wp_get_attachment_image_srcset( $attachment_id, $size ), which looks up the attachment, works out the width and height of the requested size, and hands both to the calculator.
The candidate pool is the sizes array inside the attachment’s _wp_attachment_metadata, plus one extra entry that core pushes on manually: the full size file itself. Nothing else is eligible. If a file is not recorded in that metadata array, it does not exist as far as srcset is concerned.
Each candidate then has to survive three tests, and the finished list has to survive one more.
It must match the aspect ratio of the requested size. Core calls wp_image_matches_ratio(), which constrains the larger of the two images to the width of the smaller and compares the result. The tolerance is one pixel on each side, via wp_fuzzy_number_match() with its default precision of 1. This is why a cropped size such as the 150×150 thumbnail never appears in the srcset of a landscape image. It is a different picture, not a smaller one.
It must belong to the same edit. If you rotate or crop an image in the WordPress editor, the new files get a hash in the filename: the letter e followed by 13 digits, matched by the pattern /-e[0-9]{13}/. Core reads that hash out of the src and drops any candidate that does not carry it, so leftovers from a previous edit never leak into the list.
It must be no wider than the ceiling. More on that in a moment, because it catches people out.
Then the whole list is checked. Core tracks a flag called $src_matched and only sets it when the filename in the src attribute matches one of the entries in the metadata. If it never matches, the function returns false and no srcset is written at all. This is a deliberate guard against serving the wrong picture when attachment IDs and files have drifted apart, and it is the reason a plugin that rewrites image URLs into a shape core does not recognise can wipe out responsive images across an entire site without producing a single error.
There are two more early exits worth knowing. If fewer than two candidates survive, core returns false rather than writing a single entry menu, since a srcset with one option is pointless. And GIFs are handled separately: because WordPress flattens animated GIFs when it generates sub-sizes, core never adds the full size file to a GIF’s candidate pool, and if the src is itself the full size GIF it returns false immediately. That stops a browser quietly swapping an animation for a still frame.
What comes out is a single comma separated line, with the src file placed first (a workaround for an old iOS 8 bug that core still carries) and any spaces in filenames encoded as %20. Wrapped here for readability:
srcset="/uploads/2026/07/photo-1024x576.jpg 1024w,
/uploads/2026/07/photo-300x169.jpg 300w,
/uploads/2026/07/photo-768x432.jpg 768w,
/uploads/2026/07/photo-1536x864.jpg 1536w,
/uploads/2026/07/photo-2048x1152.jpg 2048w"
Note that the list is not sorted by width and does not need to be. Core writes the candidates in metadata order, with the src moved to the front, and the browser reads all of it before choosing.
The 2048 pixel ceiling nobody mentions
Inside wp_calculate_image_srcset() there is this line:
$max_srcset_image_width = apply_filters( 'max_srcset_image_width', 2048, $size_array );
Any candidate wider than 2048 pixels is skipped, unless that file happens to be the src itself. The comparison is a strict greater than, so a file exactly 2048 pixels wide survives and a file 2049 pixels wide does not.
This collides with another core default in a way that surprises people. Since 5.3, any upload wider or taller than the big_image_size_threshold (default 2560) is scaled down, and the scaled copy becomes the working full size with -scaled appended to its filename. So a 4000 pixel photograph produces a 2560 pixel full size, and that 2560 pixel file is 512 pixels over the srcset ceiling. It is quietly dropped. The widest candidate a browser will ever see for that image is the 2048x2048 sub-size, which is exactly why core registers 1536x1536 and 2048x2048 in the first place: they are the 2x companions to medium_large and large, added specifically to feed srcset.
For most sites this default is correct. Serving a 2560 pixel file to a phone is rarely a favour. If you genuinely have a full bleed hero on a wide 2x display, raise the ceiling deliberately rather than by accident:
add_filter( 'max_srcset_image_width', 'wpie_raise_srcset_ceiling', 10, 2 );
function wpie_raise_srcset_ceiling( $max_width, $size_array ) {
return 2560;
}
Raise it only if you have also dealt with the bytes. A 2560 pixel wide file that was compressed carelessly is a multi-megabyte download you have just invited every wide screen visitor to take, which is a good moment to think hard about format and quality.
The sizes attribute is a guess, and usually a costly one
This is the part that decides whether the whole mechanism helps you or hurts you.
Core builds sizes in wp_calculate_image_sizes(), and the entire default is one line:
$sizes = sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $width );
Read literally, that says: below a viewport of $width, this image fills the entire viewport width; above that, it is exactly $width pixels wide. And $width is not your column width. It is the width of the image file that was requested. For an image inserted into post content, core takes it from the width attribute already on the tag, and falls back to the dimensions of the src file when the tag has none. Insert a 1600 pixel original at full size and you get:
sizes="(max-width: 1600px) 100vw, 1600px"
Now put that image in a 720 pixel content column, which is a completely normal blog layout, and follow what a browser does with it.
A visitor on a 1440 pixel wide laptop window matches (max-width: 1600px), so the browser believes the slot is 100vw, or 1440 CSS pixels. Device pixel ratio 1, so it needs 1440 real pixels, and it picks the smallest candidate at or above that: the 1536 pixel file. The image is then painted into 720 CSS pixels. The browser downloaded roughly four times the pixel data it could use, which for a photograph is typically three to four times the bytes, on every single page view, forever.
The correct promise for that layout, assuming 20 pixels of padding either side on small screens, is something like (max-width: 760px) calc(100vw - 40px), 720px. Same laptop, same srcset: the browser now needs 720 pixels and picks the 768 pixel candidate, roughly a quarter of the bytes for a visually identical result. Nothing about the files changed. Only the sentence describing the layout changed.
The default is not stupid, it is simply the only safe thing core can say without knowing anything. Over-promising wastes bandwidth; under-promising produces visible blur. Faced with that choice, core over-promises. But it means that on any site with a fixed width content area, the default sizes attribute is a standing lie, and fixing it is one of the cheapest performance wins available. If you are working through why your WordPress site is slow, this belongs near the top of the list, because it costs nothing to serve and applies to every image on every page.
sizes=”auto” and what it does not cover
WordPress 6.7 added a real fix for part of this. In wp_img_tag_add_auto_sizes(), core prepends the auto keyword to the sizes attribute, which tells the browser to ignore the written promise and use the image’s actual laid out width instead. Output looks like this:
sizes="auto, (max-width: 1024px) 100vw, 1024px"
Core prepends rather than replaces on purpose. Browsers that do not understand auto discard that entry and fall back to the rest of the list, so nothing breaks. There is a matching CSS rule core enqueues, img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}, which stops such images collapsing when their width is set to auto or fit-content.
Here is the catch, and it is a big one. Read the guard clauses: core only adds auto when the image already carries loading="lazy", has a width attribute, and already has a sizes attribute to prepend to. The lazy requirement comes from the spec, since a browser can only measure an element that has been laid out, and by definition a lazy image is below the fold when it loads. Meanwhile wp_omit_loading_attr_threshold() defaults to 3, so the first three content media elements core counts in the main query get no loading attribute at all, and the first eligible one gets fetchpriority="high" instead.
Which means the images that never receive sizes="auto" are precisely your hero, your featured image and the first picture in the article. The three that arrive earliest, weigh most, and set your Largest Contentful Paint. Those still get the guess.
Correcting sizes properly
The blunt instrument is the wp_calculate_image_sizes filter, which fires every time core builds a sizes value: post content, templates, the admin, REST responses. It is fine if your site really does have one content width, and a nuisance otherwise, because a sidebar thumbnail and a full width banner will both receive the same promise.
The precise instrument is the wp_content_img_tag filter, which runs once per image inside post content and gives you the finished tag plus the attachment ID. Combined with WP_HTML_Tag_Processor, core’s own HTML parser, you can set the attribute safely without regular expressions:
add_filter( 'wp_content_img_tag', 'wpie_fix_content_image_sizes', 10, 3 );
function wpie_fix_content_image_sizes( $filtered_image, $context, $attachment_id ) {
if ( 'the_content' !== $context || ! $attachment_id ) {
return $filtered_image;
}
$processor = new WP_HTML_Tag_Processor( $filtered_image );
if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
return $filtered_image;
}
$sizes = $processor->get_attribute( 'sizes' );
// Leave alone any image core did not make responsive.
if ( ! is_string( $sizes ) ) {
return $filtered_image;
}
// The real content column: 720px, with 20px padding either side below that.
$new_sizes = '(max-width: 760px) calc(100vw - 40px), 720px';
// Preserve the 'auto' keyword core adds to lazy-loaded images.
if ( wp_sizes_attribute_includes_valid_auto( $sizes ) ) {
$new_sizes = 'auto, ' . $new_sizes;
}
$processor->set_attribute( 'sizes', $new_sizes );
return $processor->get_updated_html();
}
Three details make this safe rather than merely clever. Checking $context keeps it off excerpts and widgets. Returning early when there is no sizes attribute means images core skipped stay skipped, instead of gaining a promise with no menu behind it. And re-adding auto matters because wp_content_img_tag runs after wp_img_tag_add_auto_sizes() inside wp_filter_content_tags(), so overwriting blindly would strip the keyword back off the lazy images that benefit from it most.
For images your theme outputs directly, skip the filters and just say what you mean, since wp_get_attachment_image() accepts a sizes value and will not overwrite one you supply:
echo wp_get_attachment_image(
$attachment_id,
'large',
false,
array(
'sizes' => '(max-width: 900px) 100vw, 33vw',
'class' => 'card-thumb',
)
);
That is a three column card grid described honestly: full width on phones, a third of the viewport above 900 pixels. The same courtesy applies in the editor. If a tag already carries a sizes attribute when wp_filter_content_tags() reaches it, core leaves that value alone and still adds the srcset, so a one off hero can be corrected in a Custom HTML block without any PHP. Both snippets above belong in a child theme or a small site specific plugin rather than pasted anywhere convenient, and where custom code actually belongs is worth settling before you start editing files.
Where srcset silently never happens
This accounts for most reports that a theme “does not support srcset”, and it is almost never a theme feature at all.
For images in post content, core does the work in wp_filter_content_tags(), hooked at priority 12 on the_content, the_excerpt, widget_text_content and widget_block_content. That function walks every img tag it finds and asks one question:
if ( preg_match( '/wp-image-([0-9]+)/i', $tag, $class_id ) ) {
No wp-image-123 class, no attachment ID. No attachment ID, no metadata lookup, no candidates, no srcset and no sizes. The tag passes through with only decoding="async" added, and it looks so normal that nobody notices what is missing.
Here is the difference on a real image, run through the filter both ways:
// With the class: core finds the attachment and builds the full menu.
<img src="https://cdn.wp-image-editor.com/uploads/2026/08/photo-1024x853.png" class="wp-image-4913"
width="1024" height="853" decoding="async"
srcset="/uploads/2026/08/photo-300x250.png 300w, [ ...four more... ]"
sizes="(max-width: 1024px) 100vw, 1024px" />
// Same file, class removed: nothing is added but decoding.
<img src="https://cdn.wp-image-editor.com/uploads/2026/08/photo-1024x853.png"
width="1024" height="853" decoding="async" />
Four situations produce that second tag. An img typed by hand into a Custom HTML block. A page builder that stores the image URL as a field value and prints its own markup, which many of them do. Template code that fetches wp_get_attachment_url() and echoes it into a hand written tag, rather than calling wp_get_attachment_image(). And anything rendered outside those four filters entirely, such as an ACF image field echoed raw or a template part that builds its own markup, where wp_filter_content_tags() never sees the tag at all.
That last case has an exception worth knowing, because it tells you what good looks like. Core functions that build image markup themselves do their own srcset work and do not need the content filter: wp_get_attachment_image(), the post thumbnail functions that wrap it, and get_header_image_tag() all call wp_calculate_image_srcset() directly.
So the fix in every case is the same: stop printing image tags yourself. wp_get_attachment_image() builds the srcset, the sizes, the width and height, the alt text from the attachment record, and the loading attributes, all from an attachment ID. It is one function call, and the tag it returns is strictly better than one you write by hand.
You cannot list a file that was never generated
The candidate pool is the metadata, and the metadata records what was created at upload time. Two consequences follow.
If your theme registers few sizes, the menu is short. A site whose largest registered size is large at 1024 pixels offers a 2x laptop nothing better than 1024 for a 1200 pixel hero, and the browser takes the biggest thing available and stretches it. There is no error, no warning, just a permanently soft banner.
If the original was small, the larger sizes were never made. Core refuses to upscale: image_resize_dimensions() returns false when the requested size is larger than the original in both directions, so a 900 pixel wide landscape photo produces no 1024, 1536 or 2048 pixel file and its srcset tops out at 768. Adding a registered size today also does nothing for images uploaded yesterday, because sub-sizes are generated once, at upload. New sizes only reach old images through regeneration. How one upload becomes eight or more files covers the whole generation pipeline, and it is the necessary companion to this article: srcset is only ever as good as the files behind it.
The attributes that ride along
The same pass adds three more attributes, which is worth knowing because people often attribute their effects to srcset.
decoding="async" goes on every image with a src, whether or not it got a srcset. It tells the browser it may decode the image off the main thread instead of blocking rendering.
loading="lazy" goes on images that also have width and height attributes, and only after the count of content media elements passes wp_omit_loading_attr_threshold(), which defaults to 3. This is why dimensions on your image tags matter more than they look: without them core skips both loading and fetchpriority, to avoid causing layout shift.
fetchpriority="high" goes on at most one image per page, the first one core believes is in the viewport, and only if its width times its height is at least wp_min_priority_img_pixels, which defaults to 50000 square pixels. Lazy loading and high fetch priority are mutually exclusive, and core emits a _doing_it_wrong() notice if a filter tries to set both on the same image.
How to check what your site is actually doing
Three checks tell you what WordPress srcset is really doing on a given page. Run them in order, on the page you care about.
View source and find the image. Is there a srcset at all? If not, look for a wp-image- class on the tag. Its absence is your answer and the previous section is your fix.
Read the sizes value out loud as a sentence about your layout. “Below 1600 pixels of viewport this image is the full viewport width.” Is that true? On a fixed width blog it is almost never true.
Then measure. Select the image in devtools and check two numbers in the console:
$0.currentSrc // the candidate the browser actually chose
$0.getBoundingClientRect().width // the slot it went into, in CSS pixels
Multiply the second number by window.devicePixelRatio. That product is the number of real pixels the image needs. If the width in currentSrc is far above it, your sizes is over-promising and you are paying for it on every visit. If it is below, you have a genuine sharpness problem and the answer is a larger registered size or a larger original.
Do this at a narrow window and a wide one. The two failure modes hide at opposite ends of the range, and a site can easily have both.
Rather than reading the algorithm and hoping, you can run it. Paste your srcset and your sizes below, set a viewport width and a pixel density, and the simulator names the file the browser will choose and explains why it chose it.
It also sweeps every common viewport width in one table, which is how you find the candidate that is never picked at any size, and the sizes attribute that is quietly making everyone download the 2048 pixel version.
srcset and sizes simulator
Nobody can say by hand which file a browser takes out of a srcset. Set the viewport width and the pixel density, and this tool works out the pick, the reasoning behind it, and the whole run across the common viewport widths. Everything is calculated in this browser tab, nothing is sent anywhere.
| File | Descriptor | Vs needed | Verdict |
|---|
Chrome and Firefox take the smallest candidate that reaches the needed width. Safari sometimes settles for the next one down when the gap is small, so read the pick as the common case, not a promise. Lengths in vh are resolved against an assumed viewport height of 800 px.
Symptom and cause
No srcset attribute in the source at all. The tag has no wp-image-ID class, so wp_filter_content_tags() could not resolve an attachment. Typical culprits are hand written HTML, page builder output and templates that echo a URL instead of calling wp_get_attachment_image().
srcset present but the browser downloads far more than it needs. The sizes attribute is core’s default, promising 100vw for a fixed width column. Correct it with wp_content_img_tag or by passing sizes to wp_get_attachment_image().
The largest srcset candidate is 2048 even though the original is bigger. That is max_srcset_image_width doing its job. A -scaled file at 2560 pixels is above the ceiling and is dropped unless it is the src.
srcset stops at 768 or 1024 for every image. The larger sub-sizes were never generated, either because the theme does not register them or because the originals were too small for core to make them. Regeneration only helps in the first case.
srcset disappeared across the whole site after installing a plugin. Something is rewriting src into a form that no longer matches any filename in the attachment metadata, so $src_matched stays false and core returns early. CDN and optimisation plugins are the usual suspects.
Cropped sizes never appear as candidates. That is correct behaviour. wp_image_matches_ratio() excludes anything whose aspect ratio differs by more than a pixel, because a crop is a different image, not a smaller one.
What this actually comes down to
WordPress does the hard half of responsive images very well. It generates the files, tracks them in metadata, filters out the ones that would be wrong to offer, and writes a correct srcset without being asked. The half it cannot do is the half that decides what gets downloaded, because the browser’s choice is driven by sizes, and sizes is a claim about a layout core has never seen.
So there are really two jobs. Make sure the menu exists: enough registered sizes, originals large enough to fill them, and image tags that carry an attachment ID so core can find the metadata in the first place. Then tell the truth on the sizes attribute, once, for the layouts you actually have. Most sites have two or three: the content column, a card grid, and a full width hero. Three honest sentences will cover nearly every image you publish.
Both jobs are worth about twenty minutes and neither needs a plugin. The result is not dramatic on a speed test, because no single image gets dramatically smaller. It is dramatic in aggregate, across every image on every page for every visitor, which is where image weight actually lives.