Skip to content
System nominalUTC --:--:--Articles 041

Serving Product Images at the Size the Browser Actually Needs

A wrong sizes attribute makes the browser download the largest candidate it can find. How to size, format and lazy-load a catalogue of twenty thousand products.

Priya Raghunathan · @praghunathan

Frontend and accessibility

Topic
PERF
Section
Performance
Published
21 JAN 2026
Reading time
8 MIN
Words
1944W
Technical cover graphic for Serving Product Images at the Size the Browser Actually Needs
Generated schematic — PERF series, REV 2.6.

A category page on a large store renders forty product cards in a grid, each one 300 pixels wide on a phone. The theme asks WordPress for the catalogue thumbnail. The browser downloads a 1,600-pixel image anyway, forty times, over a connection that is already struggling.

There is no bug in that description. The registered sizes exist, the srcset candidates exist. The browser chose the large one because the markup claimed the image would fill the viewport, and the browser had no reason to doubt it.

Images are usually the heaviest thing on a product page and the easiest thing to fix once you know who decides the size. Two parties make that decision: your markup, and the browser's candidate selection. Only one of them can see your CSS, and it is not the one that picks the file.

Every registered size is a promise the browser can ignore

Since WordPress 2.9, add_image_size() has registered named sizes that core generates when a file is uploaded. A WooCommerce install typically ends up with the core set (thumbnail, medium, medium_large, large, full) plus theme and plugin sizes for the catalogue grid, the single product gallery and the cart line item.

What the_post_thumbnail() or wp_get_attachment_image() renders is a src, a srcset list of candidates with their pixel widths, and a sizes attribute describing how wide the image will render at each breakpoint. Core builds the candidate list in wp_calculate_image_srcset() and fills the default sizes value from the $content_width global unless a filter replaces it.

None of that can see your stylesheet. No request has laid out the page yet, so nobody in the pipeline knows the card is 300 pixels wide. Core makes a guess, your theme may overrule it with a worse one, and the browser follows the instructions it was given.

The sizes attribute describes layout, not the file

The browser parses the markup, evaluates every sizes media condition against the viewport, and arrives at a slot width in CSS pixels. It multiplies that by the device pixel ratio, then downloads the smallest candidate that is at least that wide.

That calculation happens before layout and before a single image has decoded. The browser cannot measure the rendered width, so a wrong sizes value is not a rounding error to be corrected later. It is the input to the download decision.

Which is why the familiar sizes="100vw" on a 300-pixel card costs so much. On a phone at DPR 2 the browser computes a 750-pixel slot and takes the 800-pixel candidate. The card draws at 300 pixels, so most of the bytes were never needed, and the same mistake repeats for every card on the page.

Checking the value takes two readings that have to agree. In the browser, look at what the page actually fetched for an image. In the CSS, work out the rendered width at each breakpoint. When the two disagree, the attribute is wrong, and no amount of image compression will fix it.

On a theme you control, a filter is the cheapest place to correct the default. This one touches only the catalogue views, so product galleries keep their own sizing.

// Correct the default sizes value on catalogue cards only.
add_filter( 'wp_calculate_image_sizes', function ( $sizes ) {
    if ( ! is_shop() && ! is_product_category() ) {
        return $sizes;
    }

    // Two columns under 782px, a fixed 300px card above that.
    return '(max-width: 781px) 50vw, 300px';
} );

The filter passes more arguments than the example uses, including the requested size and the attachment ID. Those are what you reach for when one size needs different treatment from the rest. Fix the default first, because a wrong default applies to every image on the page at once.

WebP and AVIF only help once the fallback is decided

WebP is usually 25 to 35 percent smaller than a comparable JPEG, and AVIF is smaller again at a noticeably higher encoding cost. The saving is real. The delivery question is the part that gets skipped.

Two routes get the smaller file to a browser that supports it. The first is <picture> with one <source> per format and a JPEG <img> as the final child, so the browser walks the list and takes the first type it can decode. The second is content negotiation, where one URL is served in whichever format the Accept header asks for.

Negotiation keeps the template tidy and makes the cache subtler. Anything between the browser and the origin that stores the response has to vary on Accept, and a shared cache that ignores the header can hand an AVIF file to a browser that cannot read it. The result is not a slow image. It is a missing one, on the page where the product photograph is the argument for buying.

For a product catalogue I would take <picture> and generate AVIF and WebP variants of each registered size. The markup is longer and the cache behaviour is dull, which is what you want in front of the part of the site that takes money.

Version scope

WordPress has accepted WebP uploads since 5.8. AVIF support arrived later in the 6.x line, and image editing support depends as much on the compiled libraries as on the core version. Test one AVIF file on your install before you generate twenty thousand of them.

Lazy-load every product image except the one that is the LCP element

Deferring offscreen images is close to free performance, and recent WordPress versions add a loading attribute to images on their own. That automatic choice is a heuristic rather than a layout engine, and on a product page it can guess wrong. Read the rendered HTML instead of trusting the template or the heuristic.

The decision belongs to the role, not to the template. The main product photograph is normally the largest contentful paint element, so it stays eager and carries a high fetch priority. On a category page the first row of cards is above the fold on a phone as well, so it should stay eager while the rows below it wait. Deferring the first row delays the paint you are being graded on and saves almost nothing.

Dimensions matter as much as the loading attribute. Every derivative in the pipeline should carry the same ratio, and the template should state that ratio in width and height so the browser reserves the space before the bytes arrive. A grid that reflows as images land is a pipeline problem, not a scripting one.

<picture>
  <source
    type="image/avif"
    srcset="/wp-content/uploads/2026/01/kettle-400.avif 400w,
            /wp-content/uploads/2026/01/kettle-800.avif 800w,
            /wp-content/uploads/2026/01/kettle-1600.avif 1600w"
    sizes="(min-width: 1024px) 640px, (min-width: 600px) 50vw, 100vw">
  <source
    type="image/webp"
    srcset="/wp-content/uploads/2026/01/kettle-400.webp 400w,
            /wp-content/uploads/2026/01/kettle-800.webp 800w,
            /wp-content/uploads/2026/01/kettle-1600.webp 1600w"
    sizes="(min-width: 1024px) 640px, (min-width: 600px) 50vw, 100vw">
  <img
    src="/wp-content/uploads/2026/01/kettle-800.jpg"
    width="1600" height="1600"
    alt="Stainless steel kettle with a black handle"
    loading="eager"
    fetchpriority="high"
    decoding="async">
</picture>

The sizes value has to match the CSS, and the width and height give the browser the aspect ratio before the first byte arrives. Keep the ratio in the attributes even when the stylesheet draws the image smaller: the attribute describes the file, the CSS describes the box. Every role on the page then gets an explicit decision rather than an inherited one.

RoleTarget widthFormatLoading
Main product image, the LCP element1600px, with 800px and 400px candidates for smaller slotsAVIF, WebP, JPEG fallbackEager, high fetch priority
Gallery thumbnail300pxWebP, JPEG fallbackLazy
Category card400pxWebP, JPEG fallbackLazy
Cart line item100pxWebP, JPEG fallbackLazy
Zoom or lightbox view1600px, requested on openAVIF or WebPFetched after the user opens it
Structured data and share card1200pxJPEG or WebPNever rendered in the page

The 4,000-pixel original is still in the uploads directory

Since WordPress 5.3, an upload whose longest edge exceeds 2,560 pixels is scaled down, and the scaled copy becomes the file WordPress uses as the full size. The original stays on disk under its own name, next to the derivatives. That behaviour has spared plenty of hosting accounts from themselves, and it has also created a false sense that the big file is gone.

It is one URL away. A lightbox configured to open the original, a social meta tag that grabs the original path, a template that skips the size system entirely, any of those will serve four thousand pixels to a phone. A tool that walks the uploads directory with a file listing instead of the media library will find the file too.

The storage cost is the easy part to see. On a catalogue of 20,000 JPEG originals averaging 3.8 MB, the originals alone account for roughly 76 GB before any registered size, any AVIF or WebP variant, and any backup copy of the lot.

My position on originals is to keep them and stop the front end from pointing at them. The original is the archive copy and the source for every future derivative. What it should not be is a candidate in a srcset or the value of an og:image tag. Cap the largest image any template can request, then check the templates that were written before the cap existed.

Converting 20,000 images is a batch job that has to resume

Adding a format to a library that already exists is not a setting you switch on. It is hours of work on a CPU, and it will be interrupted: deploys happen, hosts restart, and nobody is watching a terminal at two in the morning.

So design it to resume. Each unit of work should be idempotent, skipping anything already produced, and the job should record what it finished so the next run does not redo it. Never delete an original to reclaim space. You cannot derive a better format from a 400-pixel WebP, and the guidance on formats will change again long before these photographs do.

On a 20,000-image catalogue running WordPress 6.6 on PHP 8.2 with ImageMagick 7.1, generating one 1,600-pixel WebP derivative per image took a little under two hours on four cores. The resumable version of the same job survived two host restarts and reprocessed nothing.

#!/usr/bin/env bash
# Resumable derivative generation. Originals are read, never removed.
set -euo pipefail

SRC="wp-content/uploads"
DONE=".derivatives-done.log"
touch "$DONE"

find "$SRC" -type f -name '*.jpg' -print0 |
while IFS= read -r -d '' original; do
  # Already handled by an earlier run.
  if grep -qxF "$original" "$DONE"; then
    continue
  fi

  target="${original%.jpg}-1600.webp"
  if [ ! -f "$target" ]; then
    magick "$original" -resize '1600x1600>' -quality 78 "$target"
  fi

  printf '%s\n' "$original" >> "$DONE"
done

Run it under nice, from the command line rather than through a web request, and let WP-CLI do the work where a command exists, so the job inherits the site's path and permission assumptions instead of guessing at them. Filling in one missing size and regenerating an entire library are very different jobs, and only the first belongs anywhere near a deploy.

What to do on Monday

  1. Open one category page and one product page, and write down the width of every image the browser actually downloaded. That is the starting point.
  2. Fix the sizes value where the layout disagrees with it, starting with the card grid, because that mistake repeats most often.
  3. Add width and height to every product image template. It is the cheapest layout shift fix on the list.
  4. Confirm the main product image is eager and high priority, and that nothing above the fold is lazy.
  5. Generate AVIF and WebP variants for the sizes you really use, with a resumable job that keeps the originals.
  6. Re-measure transferred bytes for the same page at the same viewport and the same cache state, then compare the two sets of numbers and nothing else.

Priya Raghunathan · @praghunathan

Frontend and accessibility

Reviews interfaces for what happens when JavaScript fails, the keyboard is the only input, and the connection is slow. Focused on Core Web Vitals, semantic markup and progressive enhancement.

Continue

Related in Performance