Responsive Images with srcset and sizes: A Diagnostic Workflow for Right-Sized Delivery

This guide extends the Image & Media Optimization discipline into the single highest-leverage technique for cutting transfer weight: serving each viewport the smallest image that still looks sharp. A fixed <img src> ships one file to every client, which means a 1600px hero gets pushed to a 360px phone that can only ever display a fraction of those pixels. On a mid-tier mobile connection (~1.6 Mbps), every 100KB of wasted image payload adds roughly 0.5s of transfer time, and because the hero image is almost always the Largest Contentful Paint candidate, that waste lands directly on your LCP budget of 2.5s. Responsive image markup — srcset, sizes, and <picture> — lets the browser select the right candidate before it commits a single byte to the network.

The goal of this workflow is mechanical precision: declare a set of candidate files, describe the layout slot they will occupy, and let the browser's selection algorithm pick the smallest file that satisfies the device's pixel density. Get the inputs right and a phone downloads a 40KB image where a desktop downloads 180KB — same markup, same component.

srcset candidate selection The browser multiplies the resolved sizes slot width by devicePixelRatio to get a required pixel width, then picks the smallest w-descriptor candidate that meets or exceeds it. How the browser picks a srcset candidate 1. Resolve sizes slot width = 360 CSS px (first matching media) 2. Apply DPR 360 x 2 (retina) = 720 device px needed 3. Pick candidate smallest w >= 720 chosen: 800w srcset="img-400.jpg 400w, img-800.jpg 800w, img-1600.jpg 1600w" 400w 800w (used) 1600w A wrong sizes value poisons every candidate decision — the byte cost is silent. Browsers may pick a larger file under good network conditions; never a smaller one than required.

1. Environment Setup and Source Asset Generation

Responsive delivery is only as good as the candidate set behind it, so the first step is generating a ladder of derivatives from each master asset. A practical ladder spans the full range of real layout widths multiplied by plausible device pixel ratios: for a content image that renders at most 800 CSS pixels wide, you want candidates up to 1600px to satisfy a 2x display. Generate widths at roughly 1.5x steps — 400, 600, 800, 1200, 1600 — which keeps adjacent candidates close enough that the browser rarely over-fetches by more than a few percent.

Use a build-time tool such as sharp (Node) or your image CDN's transformation API rather than hand-exporting in a design tool. Hand exports drift out of sync and you lose the ability to regenerate the whole ladder when you add a new breakpoint. Pair this with modern formats from the start; the same candidate ladder should exist in AVIF and WebP as covered in serving AVIF and WebP with fallbacks, because format choice and resolution selection are orthogonal — srcset chooses the size, the type negotiation chooses the codec.

The width of the topmost candidate is the one decision that bounds quality: the master asset must be at least as wide as your largest CSS slot multiplied by the highest DPR you intend to serve sharply. For a 1100px content column targeting DPR 2, that is 2200px of intrinsic width, so a 1600w ceiling will still look soft on a retina laptop. Resist the temptation to fix that with image-rendering or a sharpening filter — neither adds detail that was never encoded. The only cure for a pixel deficit is more source pixels, which is why the ladder, not the markup, is where high-DPI sharpness is won or lost. Conversely, do not let the ladder run wider than the master; resizing up from the master produces interpolated mush that ships real bytes for fake detail, so always assert that the master exceeds the largest width before generating.

javascript
// build-images.js — generate a width ladder with sharp
import sharp from 'sharp';

const widths = [400, 600, 800, 1200, 1600];
for (const w of widths) {
  await sharp('hero-master.jpg')
    .resize({ width: w })          // never upscale: master must exceed the largest width
    .jpeg({ quality: 72 })          // 72 is a good byte/quality knee for photos
    .toFile(`hero-${w}.jpg`);
}
// trade-off: a 5-width ladder doubles storage and build time vs a single file.
// For tiny UI icons or images that never resize, skip srcset entirely — the
// markup overhead and extra cache entries cost more than they save.

2. Capture a Byte Baseline Across Viewports

Before adding markup, quantify the waste you are paying for. Open Chrome DevTools, throttle the Network tab to Fast 3G, and load the page at three widths: 360px (phone), 768px (tablet), and 1440px (desktop). For each, note the transferred size of the image in the Network panel and the resolved render size in the Elements panel (Rendered size under the Computed tab). The gap between intrinsic file dimensions and rendered dimensions is your over-download. A 1600px file rendered into a 360px slot at DPR 2 needs only 720 device pixels — you are shipping roughly 4x the necessary pixel area, which scales to far more than 4x the bytes once compression non-linearity is factored in.

Record these numbers as your baseline the same way you would capture an LCP baseline. The actionable target is straightforward: the transferred image width should land within one ladder step above the required device-pixel width, never the top of the ladder on a small screen. If your phone load is pulling the 1600w file, the markup is either missing or the sizes attribute is wrong — both are fixed in the next steps.

Capture two derived numbers alongside the raw bytes, because they convert the abstract waste into a budget you can defend in review. First, the over-fetch ratio: transferred pixel area divided by required pixel area (rendered width × DPR)². A ratio near 1.0 is ideal; anything above 2.0 means you are paying double for pixels the screen cannot resolve. Second, the incremental transfer time at your target connection: at ~1.6 Mbps an extra 100KB costs roughly 0.5s, and on the LCP image that time is added directly to the loading metric. Tabulating these three columns — bytes, over-fetch ratio, added seconds — across the three viewports gives you a before/after scorecard that makes the impact of the fix concrete rather than anecdotal, and it surfaces the worst offender so you optimize the image that actually moves the metric first.

3. Isolate the Bottleneck: Density vs Width Descriptors

srcset accepts two mutually exclusive descriptor syntaxes, and choosing the wrong one is the most common structural mistake. Density descriptors (x) describe candidates for a fixed-size image: logo.png 1x, logo@2x.png 2x tells the browser "use the 2x file on a 2x display." This is correct only when the image renders at one constant CSS size regardless of viewport — logos, avatars, fixed-width UI chrome. The browser picks purely on devicePixelRatio; the layout width is irrelevant.

Width descriptors (w) describe the intrinsic pixel width of each candidate file and are the right choice for any image whose rendered size changes with the viewport. With w, the browser cannot guess the layout slot from the markup alone — a flexible image could be full-bleed or sit in a sidebar — so you must supply a sizes attribute telling it how wide the slot will be. The browser combines the resolved sizes width with devicePixelRatio to compute the required device-pixel width, then selects the smallest candidate that meets or exceeds it. Mixing the two syntaxes in one srcset is invalid; pick x for fixed-size images and w for fluid ones.

html
<!-- Density (x): correct ONLY for a fixed-render-size image like a logo -->
<img src="logo-160.png"
     srcset="logo-160.png 1x, logo-320.png 2x"
     width="160" height="48" alt="Acme">
<!-- trade-off: x-descriptors ignore viewport width entirely. Use them on a
     responsive content image and a phone on a 3x screen will download your
     largest file even inside a 320px slot. -->

4. Apply the Fix: srcset + sizes for Fluid Images

For content images, the corrected markup pairs a w ladder with a sizes attribute that mirrors your CSS layout. The sizes attribute is a list of (media-condition) length pairs evaluated left to right; the first matching condition wins, and a bare final length is the fallback. Crucially, the lengths must reflect the actual rendered width at each breakpoint — including any container max-width, gutters, and grid columns — not the viewport width. A common failure is writing sizes="100vw" for an image that actually sits in a 720px content column on desktop, which forces the browser to fetch a 1440px-class file for a slot half that size.

Always set explicit width and height attributes (or an aspect-ratio in CSS) alongside srcset to reserve layout space and avoid the Cumulative Layout Shift that comes from images snapping in after load. The intrinsic attributes establish the aspect ratio; srcset swaps the actual pixels behind it.

html
<!-- Width (w) + sizes: the right pattern for a fluid content image -->
<img
  src="hero-800.jpg"
  srcset="hero-400.jpg 400w, hero-600.jpg 600w, hero-800.jpg 800w,
          hero-1200.jpg 1200w, hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1024px) 50vw,
         720px"
  width="1200" height="675"
  alt="Quarterly revenue dashboard"
  decoding="async">
<!-- trade-off: sizes is a PROMISE about layout. If CSS later changes the slot
     width and sizes is not updated, the browser keeps choosing against stale
     assumptions and silently over- or under-fetches. Treat sizes as coupled to
     your CSS and lint it when breakpoints change. -->

5. Art Direction with the picture Element

srcset and sizes solve resolution switching — same image, different pixel counts. They cannot change the crop or aspect ratio between breakpoints, which is what art direction requires: a wide cinematic hero on desktop that becomes a tight square crop on mobile so the subject stays legible. For that you need <picture> with <source media="...">. Each <source> carries its own srcset and sizes, and the browser uses the first <source> whose media matches, falling back to the <img>. The inner <img> is mandatory — it is the element that actually renders and carries alt, width, and height.

<picture> is also the mechanism for format fallback via type, which overlaps with the codec negotiation in serving AVIF and WebP with fallbacks. The two concerns compose: media sources handle art direction, type sources handle codec selection, and srcset inside each handles resolution.

html
<!-- picture for art direction: different CROP per breakpoint -->
<picture>
  <source media="(max-width: 600px)"
          srcset="hero-square-400.jpg 400w, hero-square-800.jpg 800w"
          sizes="100vw">
  <source media="(min-width: 601px)"
          srcset="hero-wide-800.jpg 800w, hero-wide-1600.jpg 1600w"
          sizes="(max-width: 1024px) 100vw, 1100px">
  <img src="hero-wide-800.jpg" width="1600" height="600"
       alt="Product team at launch" decoding="async">
</picture>
<!-- trade-off: picture/media is verbose and locks crops to fixed breakpoints.
     If you only need different sizes (not different crops), plain img+srcset+sizes
     is simpler and lets the browser interpolate freely across the width ladder. -->

Deconstructing the Browser's Selection Algorithm

Understanding why the browser chose a file is the difference between guessing and fixing. With w descriptors the selection proceeds in three deterministic phases. First, resolve sizes: the browser walks the sizes list, evaluates each media condition against the current viewport, and takes the length from the first match — this yields the slot width in CSS pixels. A vw length is resolved against the layout viewport; an absolute length is used as-is. Second, apply density: it multiplies that CSS width by the effective devicePixelRatio (2 on most modern phones and retina laptops, sometimes 3 on flagship phones) to get the required device pixel width. Third, select the candidate: it picks the smallest srcset entry whose w value is greater than or equal to the required width.

Two browser behaviors surprise engineers. First, the choice is made early, during preload scanning, often before CSS has fully applied — which is why sizes exists at all, because the browser cannot yet measure the real slot. Second, browsers are permitted to choose a larger candidate than strictly required when the network is fast or the image is cached, and to choose more conservatively on slow connections via the Network Information API. They will never choose smaller than required, so an under-provisioned sizes always causes blur, never savings. Once a candidate is chosen for a given DPR it is typically cached and reused, so resizing the window down does not re-fetch a smaller file. This is why testing at multiple discrete widths with a fresh load matters more than dragging the window.

It also explains a class of bug that looks like a framework defect but is really the spec working as designed. Because selection runs against the resolved sizes length and the current DPR at request time, a slot whose width depends on JavaScript-applied classes, late-loading fonts, or a layout that only settles after hydration can have its image requested against a transient slot width. The preload scanner fires while the document is still 100vw wide and commits to a candidate before your grid collapses the image into a sidebar — so the byte cost is locked in before the final layout exists. The defensible mitigation is to keep the layout that determines image width stable and declarative (CSS, not JS) above the fold, and to make sizes describe the settled layout rather than the initial paint. When the two genuinely diverge, an explicit <link rel="preload" as="image" imagesrcset=... imagesizes=...> lets you state the intended candidate to the scanner directly instead of leaving it to infer from a half-built DOM.

Advanced Diagnostics and Framework Edge Cases

Component frameworks abstract this markup behind image components — Next.js <Image>, Nuxt <NuxtImg>, Astro <Image> — which auto-generate srcset and a default sizes. The default sizes is almost always 100vw, which is correct for full-bleed heroes and badly wrong for constrained content images, causing systematic over-fetching across an entire app. Always pass an explicit sizes prop that matches the component's real layout slot; this single prop is the highest-impact image optimization in most framework codebases. Inspect the generated <img> in DevTools to confirm the srcset ladder and sizes are what you intended.

A subtler failure mode is the currentSrc mismatch. Read document.querySelector('img').currentSrc in the console to see exactly which candidate the browser actually selected — if it disagrees with your mental model, your sizes is the culprit. Watch also for DPR edge cases on zoomed desktop browsers and Windows display scaling, where effective DPR can be 1.25 or 1.5, nudging the browser up a ladder step. For the LCP image specifically, responsive selection interacts with priority: the right-sized file still needs to be discovered and fetched early, which is where image CDNs and fetchpriority come in to ensure the chosen candidate is requested with high priority rather than waiting behind the preload scanner's default ordering.

Validation and Performance Budgeting

Validation closes the loop opened by your baseline. Re-run the three-viewport measurement and confirm the transferred width now tracks the device-pixel requirement at each breakpoint. The concrete budget: on a 360px phone at DPR 2, the chosen image should be the ~800w candidate (≈40–70KB for a JPEG), not the 1600w file (≈180KB+). On desktop in a 720px slot at DPR 1, expect the 800w candidate; at DPR 2, the 1200–1600w range. Any phone load pulling a >1000w file is a sizes bug.

Enforce this in CI rather than relying on manual checks. Lighthouse flags uses-responsive-images and uses-optimized-images audits; assert against them so a regression — someone deletes a sizes prop or changes a layout without updating sizes — fails the build. Pair this with a hard byte budget on the LCP image, since responsive over-fetching is a leading cause of LCP regressions that pass functional tests.

json
{
  "ci": {
    "assert": {
      "assertions": {
        "uses-responsive-images": ["error", { "minScore": 1 }],
        "uses-optimized-images": ["error", { "minScore": 1 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
      }
    }
  }
}

Use uses-responsive-images to catch missing or wrong srcset/sizes before merge.