Front-end performance
Image Pipelines and Responsive Markup
WordPress generates the srcset and sizes attributes for you, and both are guesses about your layout. How to read them, correct them, and measure the difference.
A 4000 by 3000 photograph leaves a camera as a 2.3 MB JPEG. Drop it into the media
library and WordPress scales it, generates six intermediate sizes, writes
srcset and sizes, adds decoding and possibly
loading and fetchpriority, and prints an img
element. All of that is a guess about your layout.
Most of the time the guess is close enough. When it is wrong, it is wrong on the largest element of the page, which is the element that decides whether the page feels fast.
The measurements below come from a WordPress 7.0.7 install on PHP 8.5.6 with a generated 4000 by 3000 test image. Every claim about a core function was read from that install's source, not recalled.
WordPress writes the srcset and sizes attributes for you
Here is the real markup a single post produced, with the featured image attached and rendered by the post featured image block in Twenty Twenty-Five.
<img width="2560" height="1920"
src="hero-original-scaled.jpg"
class="attachment-post-thumbnail size-post-thumbnail wp-post-image"
alt="" decoding="async" fetchpriority="high"
srcset="hero-original-scaled.jpg 2560w,
hero-original-2048x1536.jpg 2048w,
hero-original-1536x1152.jpg 1536w,
hero-original-1024x768.jpg 1024w,
hero-original-768x576.jpg 768w,
hero-original-300x225.jpg 300w"
sizes="(max-width: 2560px) 100vw, 2560px" />
Every attribute has a rule behind it. width and height come
from the requested size, here post-thumbnail resolved to the scaled original at
2560 by 1920, and decoding="async" is added for every image. The
srcset candidates are the registered sizes whose aspect ratio matches, capped
by the max_srcset_image_width filter at 2048 pixels, which is why the 2560
version is the src and never a candidate. And sizes comes from
wp_calculate_image_sizes(), which with nothing supplied returns
(max-width: {width}px) 100vw, {width}px. A request for large on
this attachment produced (max-width: 1024px) 100vw, 1024px, confirmed by
calling wp_get_attachment_image_sizes() rather than by reading a
docblock.
One attribute needs separate attention. Since WordPress 6.7 a lazy-loaded responsive
image gets sizes="auto, ..." prepended so the browser can compute the real
rendered width, but that prefix is conditional: call
wp_get_attachment_image() with array( 'loading' => 'eager' )
and the auto keyword disappears. The image you are about to mark as the LCP
element is that image, so fixing its loading attribute removes the only mechanism that
would have corrected its sizes.
sizes describes layout, so a wrong value buys the wrong file
sizes is the only place the browser learns how wide the image will be
displayed, so a wrong value buys a candidate for a layout you do not have.
The default (max-width: 1024px) 100vw, 1024px undersizes a full-bleed hero:
it claims the image stops growing at 1024 pixels, so a 1440-pixel desktop downloads the 1024
by 768 candidate at 63,808 bytes and stretches it. Sharpness is what you lose, not
speed.
The same default oversizes a card, and that direction is expensive. A grid that calls
the_post_thumbnail( 'full' ) produces
sizes="auto, (max-width: 2560px) 100vw, 2560px", so a 360-pixel card tells the
browser it is a full-viewport image. On this attachment the candidates run from 9,041 bytes
at 300 pixels wide to 365,651 at 2560, a forty-fold difference on the element that decides
whether the grid feels instant.
Pass the value yourself:
// A full-bleed hero: 100vw until the layout caps the content width.
echo wp_get_attachment_image(
$attachment_id,
'full',
false,
array(
'sizes' => '(max-width: 1440px) 100vw, 1440px',
'loading' => 'eager',
'fetchpriority' => 'high',
)
);
// A thumbnail in a three-column grid that renders 360 CSS pixels per card.
echo wp_get_attachment_image(
$attachment_id,
'medium_large',
false,
array( 'sizes' => '(max-width: 600px) 100vw, 360px' )
);
When the mistake is theme-wide rather than in one template, filter it. The
wp_calculate_image_sizes filter receives the computed string with the size and
the attachment ID, and your return value replaces it before the markup is built.
wp_get_attachment_image_attributes receives the whole attribute array
immediately before the tag is assembled, which is where you would add loading or
fetchpriority for one attachment.
The original is the most expensive file in the uploads directory
The big_image_size_threshold filter, at its default of 2560, makes the
uploader write a scaled copy, set it as full, and record the original under
original_image in the metadata. The measured result:
| File | Dimensions | Bytes | What loads it |
|---|---|---|---|
thumbnail | 150 by 150 | 3,895 | Admin lists and small cards |
medium | 300 by 225 | 9,041 | Small srcset candidates |
medium_large | 768 by 576 | 38,280 | Narrow viewports |
large | 1024 by 768 | 63,808 | Content columns |
1536x1536 | 1536 by 1152 | 131,747 | Wide desktop, high pixel ratio |
2048x2048 | 2048 by 1536 | 231,934 | The widest srcset candidate |
full | 2560 by 1920 | 365,651 | The src, and full requests |
| original | 4000 by 3000 | 2,320,486 | Nothing, normally |
The original is 2.2 MB and no sane template loads it. It is a disk and backup cost, and a risk if some template requests a size that resolves to it. The cost you pay is whichever candidate the browser picks, and the device decides that as much as your CSS does.
image_editor_output_format changes what the editor writes
WordPress will not convert your JPEGs unless you ask. In 7.0.7 the filter's default mapping covers HEIC and HEIF input to JPEG and nothing else, a default that changed in 6.7 from an empty array.
add_filter( 'image_editor_output_format', function ( $formats, $filename, $mime_type ) {
$formats['image/jpeg'] = 'image/webp';
$formats['image/png'] = 'image/webp';
return $formats;
}, 10, 3 );
With that in place and a fresh upload, WordPress wrote WebP for every generated size and
for the full image: 63,808 bytes down to 37,066 for the 1024 size, and 2,320,486 down to
1,185,802 for the full size. Two details are easy to miss. The attachment's MIME type stays
image/jpeg while the metadata points at a .webp file, and the
original JPEG stays in the uploads directory beside the WebP copy.
Two warnings come from doing this on a live install. It only affects uploads that happen
after the filter is active, so existing images need
wp_generate_attachment_metadata() run over them as a background job. And the
filter is called on the size-generation path with $filename set to
null, so a callback that type-hints string $filename throws a
fatal error. That is not hypothetical; it happened to me while writing this.
So the format name is not the decision. The AVIF run on this install produced a 1024 by
768 file at 70,641 bytes, larger than the JPEG at the same nominal quality and nearly double
the WebP, because WordPress passes the same quality number to imagewebp() and
imageavif() and the encoders do not interpret it the same way. The default
quality is 82 and is adjustable through wp_editor_set_quality. AVIF being
smaller in general does not make it smaller under your settings, so measure before
switching.
Lazy loading is right for every image except one
WordPress has added loading="lazy" by default since 5.5 and decides per image
inside wp_get_loading_optimization_attributes(), so the same call can produce
opposite markup depending on where the template makes it.
An image in the main loop counts as content media, and the first
wp_omit_loading_attr_threshold() images, three by default since 6.3, count as
in the viewport, so the loading attribute is omitted rather than set to
eager. Images in the header contexts named by
wp_loading_optimization_force_header_contexts also count. Everything the
function cannot place there gets loading="lazy".
That is why the featured image above has no loading attribute, and why a
hero rendered from a template part outside the loop does. I reproduced the second case by
calling wp_get_attachment_image() away from the loop, and the same attachment
came back with loading="lazy" on it. A lazy hero is the expensive version of
this bug, because the browser will not start the request until it knows the element is near
the viewport, which is the delay the LCP subpart metrics call resource load delay.
So find the element that will be the LCP image, call it with
array( 'loading' => 'eager' ), and leave everything else lazy. Two
mechanisms can act on one tag, so check the rendered HTML rather than a plugin setting.
fetchpriority="high" is a claim you can make about one element
Core usually does this for you. The featured image above came back with
fetchpriority="high" unaided, because
wp_maybe_add_fetchpriority_high_attr() grants the flag to the first eligible
image in the viewport, once per request, held by
wp_high_priority_element_flag(). Eligible means a width and
height whose product clears the wp_min_priority_img_pixels filter,
50,000 square pixels by default, about 224 by 224.
Two behaviours there are worth knowing before you fight it. It refuses the flag to an
image that is already lazy, and marking an image both lazy and high priority triggers a
_doing_it_wrong() notice, which has been the case since 6.3.
When core gets it wrong, the LCP element is usually not an image WordPress generated: a
CSS background, an image a slider's JavaScript injects, or a theme passing
fetchpriority="auto". Set it yourself in those cases, on the one element you
have verified is the LCP element, because the attribute on four images is worth the same as
on none. The browser already fetches in document order.
Measure a hero before you change the pipeline
Format decisions are cheap to get wrong, so measure one real image. I generated a 4000 by 3000 photographic scene, resized it with Lanczos to eight widths, and encoded each width three ways: JPEG at quality 82 progressive, WebP at quality 80 with method 6, and AVIF at quality 52 with speed 4, through Pillow 12.3.0 with its libwebp and libavif build.
| Width | JPEG q82 | WebP q80 | AVIF q52 | WebP saving | AVIF saving |
|---|---|---|---|---|---|
| 2560 | 350,259 | 151,312 | 73,269 | 57% | 79% |
| 2048 | 219,299 | 83,680 | 49,481 | 62% | 77% |
| 1920 | 192,141 | 74,112 | 45,583 | 61% | 76% |
| 1536 | 125,549 | 47,500 | 32,190 | 62% | 74% |
| 1024 | 61,581 | 23,574 | 17,168 | 62% | 72% |
| 768 | 37,498 | 14,914 | 10,595 | 60% | 72% |
| 480 | 17,436 | 7,676 | 5,390 | 56% | 69% |
| 320 | 9,332 | 4,186 | 3,019 | 55% | 68% |
Two rows matter more than the format columns. The 1024 row is what a ratio 1 desktop
fetches on a full-bleed 1440-pixel hero under the default sizes, and the 2560
row is what a ratio 2 laptop fetches once you correct sizes. That correction
multiplies the transfer by five on the desktop that was being undersized, which is the
trade: a sharp hero in exchange for bytes.
That trade is usually right for the LCP element, because a blurry hero is the first thing a visitor judges, and often wrong for everything else.
Take a hero image on a staging site and produce a width and format report for it.
Record the original's dimensions and byte size, then list every generated size with
its dimensions and bytes. Pull the rendered img element for the LCP element
off the page and write down its srcset and sizes exactly as
served.
Compute what the browser will fetch for a 1440 CSS pixel viewport at ratio 1, the
same viewport at ratio 2, and a 390 pixel phone at ratio 3. Show your arithmetic: the
device pixels the slot needs, then the smallest candidate whose w
descriptor covers it.
Correct sizes to match your CSS, add loading="eager" and
fetchpriority="high" if that element is the LCP image, and re-render to
prove the attributes changed. Then answer: did the correction make the page heavier or
lighter on the ratio 1 desktop, and was that the right trade?
Show the solution and the reasoning
The report is a table with one row per generated size plus three rows for the fetch decisions. Writing the arithmetic down is the part that matters, because it is the only way to notice that a ratio 2 laptop asks for a file you have never looked at.
On the trade, the honest answer is usually that the correction makes the page heavier on
the desktop and that it is still right for the LCP element. Two things change that answer.
If your CSS caps the hero's width, put the cap in sizes, and the ratio 2
laptop stops asking for the widest candidate. And if the hero is not the LCP element, you
are paying those bytes for nothing, so check
largest_contentful_paint_resource_type first.
On format there is more than one defensible answer. WebP is in every browser that matters and saved around sixty percent over the JPEG above, so it is the safe choice. AVIF saves another fifteen to twenty percent, but only under encoder settings that produce a smaller file on your image, and the default GD settings here did not.
Two things the exercise does not settle. It does not prove which file the browser
chose, because the browser also reads media descriptors and any
auto keyword in sizes, so confirm with a network panel before
writing a prediction down as fact. And it does not cover an image that lives only in a
saved block's markup: core/image is static, so the srcset and
sizes on a published page were written into post_content when
the post was saved, and a later filter does not rewrite them. Editing the post does.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.