Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 11 / 18 35 MIN

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:

FileDimensionsBytesWhat loads it
thumbnail150 by 1503,895Admin lists and small cards
medium300 by 2259,041Small srcset candidates
medium_large768 by 57638,280Narrow viewports
large1024 by 76863,808Content columns
1536x15361536 by 1152131,747Wide desktop, high pixel ratio
2048x20482048 by 1536231,934The widest srcset candidate
full2560 by 1920365,651The src, and full requests
original4000 by 30002,320,486Nothing, 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.

WidthJPEG q82WebP q80AVIF q52WebP savingAVIF saving
2560350,259151,31273,26957%79%
2048219,29983,68049,48162%77%
1920192,14174,11245,58361%76%
1536125,54947,50032,19062%74%
102461,58123,57417,16862%72%
76837,49814,91410,59560%72%
48017,4367,6765,39056%69%
3209,3324,1863,01955%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.

Lab 40 minutes

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.