Web Development

Responsive Images in 2026: srcset, sizes, and the Cases That Still Trip People Up

srcset and sizes look simple until a real layout breaks them — the exact case where a sidebar image kept loading the largest file on every screen size, and why.

By Aissam Ait Ahmed Web Development 0 comments

srcset and sizes get treated as a matched pair that's easy to configure once and forget, and that assumption breaks the moment an image sits inside a layout that changes shape at different breakpoints — a sidebar image that's 300px wide on desktop and full-width on mobile needs a fundamentally different sizes value than a hero image that's always full-width, and using the same lazy default for both is exactly how a page ends up serving a 2000px image to a 300px slot.

What srcset and sizes are actually each responsible for

srcset lists the image files available and their real pixel widths. sizes tells the browser how wide the image will actually be rendered at each viewport width, before any image has loaded. The browser combines the two — using sizes to figure out the rendered width, then picking the smallest srcset candidate that comfortably covers that width at the device's pixel density — to decide which file to download. Getting either one wrong independently produces the same symptom: the wrong file size gets loaded, but for different underlying reasons.

<img
    srcset="hero-480.jpg 480w, hero-960.jpg 960w, hero-1920.jpg 1920w"
    sizes="(max-width: 600px) 100vw, 50vw"
    src="hero-960.jpg"
    alt="Team standup meeting"
>

This says: below 600px viewport width, the image renders at 100% of the viewport; above that, 50%. The browser does the arithmetic itself — on a 1200px-wide viewport above the breakpoint, the image renders at 600px, and the browser picks the smallest candidate at or above 600px times the device's pixel ratio, which on a standard-density screen is hero-960.jpg.

The sidebar case that broke this in practice

A sidebar image on a real page was written with a sizes value that assumed the layout it lived in on the design mockup: a fixed 300px-wide sidebar column across all screen sizes. On mobile, the actual layout collapsed the sidebar to full width, stacking it above the main content — a real, deliberate responsive design decision the sizes attribute simply hadn't been updated to reflect:

<!-- Wrong: assumes 300px always, even though mobile shows it full-width -->
<img
    srcset="sidebar-300.jpg 300w, sidebar-600.jpg 600w, sidebar-1200.jpg 1200w"
    sizes="300px"
    src="sidebar-600.jpg"
    alt="Product feature illustration"
>

Because sizes="300px" told the browser the image would always render at 300px, the browser always selected sidebar-300.jpg — including on mobile, where the image actually rendered at the full device width, often 380px or more, meaning a 300px source file was being stretched up past its native resolution and rendering visibly soft and blurry on exactly the devices most likely to be on a slower connection to begin with.

<!-- Fixed: sizes reflects the actual layout at each breakpoint -->
<img
    srcset="sidebar-300.jpg 300w, sidebar-600.jpg 600w, sidebar-1200.jpg 1200w"
    sizes="(max-width: 768px) 100vw, 300px"
    src="sidebar-600.jpg"
    alt="Product feature illustration"
>

The fix wasn't a code problem so much as a synchronization problem: sizes has to be kept honest against the actual CSS layout, and the two live in genuinely different files, maintained by different people in a lot of real teams — a designer or frontend developer changes a breakpoint in CSS, and nothing forces a corresponding update to every sizes attribute referencing that layout elsewhere in the templates.

A second, subtler case: sizes that's technically correct but still wasteful

A second issue on the same page: a hero image used sizes="100vw" unconditionally, which is technically accurate — the image really does render at full viewport width on every screen size — and still wasteful, because it ignores container max-widths. A page with a 1200px max-width container renders that "full width" hero at 1200px on any viewport wider than that, not the actual viewport width, so 100vw tells the browser to fetch an image sized for viewports that can be 2560px or wider, well past what the layout will ever actually display.

<!-- Technically true, but ignores the container's max-width -->
<img srcset="hero-800.jpg 800w, hero-1200.jpg 1200w, hero-2400.jpg 2400w"
     sizes="100vw"
     src="hero-1200.jpg" alt="Hero banner" >

<!-- Accounts for the actual container constraint -->
<img srcset="hero-800.jpg 800w, hero-1200.jpg 1200w, hero-2400.jpg 2400w"
     sizes="(min-width: 1200px) 1200px, 100vw"
     src="hero-1200.jpg" alt="Hero banner" >

On a 2560px monitor, the first version fetches hero-2400.jpg for a container that will only ever display it at 1200px — nearly double the necessary file size, downloaded and decoded for no visual benefit at all. The corrected version caps the assumed width at the container's actual maximum, which is a small change with a real bandwidth impact on wide-monitor visitors, who are otherwise easy to overlook since a wide monitor doesn't intuitively suggest "wasting bandwidth."

The picture element for genuinely different images, not just different sizes

srcset/sizes assumes the same image content at different resolutions. <picture> is for a genuinely different case: serving a different crop or composition at different breakpoints, not just a smaller file of the same image.

<picture>
    <source media="(max-width: 600px)" srcset="hero-mobile-crop.jpg">
    <source media="(min-width: 601px)" srcset="hero-wide-crop.jpg">
    <img src="hero-wide-crop.jpg" alt="Team celebrating a product launch">
</picture>

A wide landscape hero photo often has important content — a person's face, a key detail — that gets awkwardly cropped or shrunk into insignificance when the same image is simply scaled down for a narrow mobile viewport. <picture> with media conditions lets a genuinely different, mobile-appropriate crop replace the desktop image entirely rather than just serving a smaller version of the same composition. Reaching for <picture> when the actual need is just "smaller file at smaller widths" — the far more common case — adds unnecessary markup complexity; it earns its place specifically when the art direction, not just the resolution, needs to change.

Modern formats: adding AVIF and WebP without breaking older browsers

<picture>
    <source type="image/avif" srcset="hero-480.avif 480w, hero-960.avif 960w, hero-1920.avif 1920w" sizes="(max-width: 600px) 100vw, 50vw">
    <source type="image/webp" srcset="hero-480.webp 480w, hero-960.webp 960w, hero-1920.webp 1920w" sizes="(max-width: 600px) 100vw, 50vw">
    <img srcset="hero-480.jpg 480w, hero-960.jpg 960w, hero-1920.jpg 1920w"
         sizes="(max-width: 600px) 100vw, 50vw"
         src="hero-960.jpg" alt="Team standup meeting">
</picture>

The browser evaluates <source> elements in order and uses the first one whose type it supports, falling through to the plain <img> as a guaranteed-compatible last resort — AVIF first for browsers that support it (typically the smallest file for equivalent visual quality), WebP as a broadly-supported middle tier, and a plain JPEG or PNG as the fallback nothing modern actually needs but every browser can render. Each <source> needs its own sizes attribute matching the fallback's, since the browser's selection logic runs independently per source.

A practical checklist before shipping a responsive image

  • Does sizes actually match the real CSS layout at every breakpoint that changes the image's rendered width, not just the layout as it looked in the original design mockup?
  • Does sizes account for container max-widths, or does it assume the image scales with the raw viewport indefinitely on wide screens?
  • Is this actually a resolution problem (srcset/sizes) or a composition problem (<picture> with media)? Using the wrong tool for the case adds either unnecessary markup or a visually awkward crop.
  • Are modern formats offered with a guaranteed fallback, rather than assuming every visitor's browser supports AVIF or WebP?

Testing the actual selected file at a few real breakpoints — not just trusting the markup looks right — is worth doing before shipping; browser devtools' network tab shows exactly which candidate got requested at the current viewport width, which is the fastest way to catch a sizes mismatch like the sidebar case above before a real visitor does. If you're compressing the source files themselves before generating the different width variants, this site's own image compressor is a reasonable place to do that pass, and if you're working through other layout-shape problems in the same responsive-design vein, CSS container queries in practice covers a closely related, newer tool for handling a component's layout changing based on its container rather than the viewport.

Generating the actual width variants without manual exporting

Writing a correct srcset is only half the job — someone or something still has to actually produce a 480px, 960px, and 1920px version of the same source image, and doing that by hand in an image editor for every image on a content-heavy site doesn't scale past a handful of pages. A build-time step, run as part of the deploy pipeline rather than manually per image, keeps this consistent and removes the temptation to skip a size tier because generating it by hand felt like too much friction for one more image:

// A build script using sharp, generating standard width tiers
const widths = [480, 960, 1920];

for (const width of widths) {
    await sharp(sourcePath)
        .resize({ width })
        .toFormat('avif', { quality: 60 })
        .toFile(`${outputDir}/${baseName}-${width}.avif`);

    await sharp(sourcePath)
        .resize({ width })
        .toFormat('jpg', { quality: 80 })
        .toFile(`${outputDir}/${baseName}-${width}.jpg`);
}

Running this automatically against every uploaded image, rather than relying on a content editor to remember to export three separate sizes manually every time, is what actually makes a correct srcset sustainable across a site with more than a handful of pages — the markup pattern shown throughout this post only stays correct in practice if the files it references reliably exist at the sizes it claims, and that reliability comes from automating the generation step, not from disciplined manual habit.

A quick note on lazy loading, since it interacts with all of this

Native loading="lazy" is a natural companion to everything above, deferring the download of below-the-fold images until they're close to entering the viewport — but it's worth being deliberate about which images get it. Applying loading="lazy" to an above-the-fold hero image delays exactly the image most likely to be the page's Largest Contentful Paint element, actively working against page-speed goals rather than helping them. The practical rule: lazy-load everything below the first viewport's worth of content, and explicitly leave it off — or set fetchpriority="high" — on whatever image renders first, since that one needs to load as early as possible, not later.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools