Setting Responsive Image Byte Budgets

The most common responsive-image failure is invisible: the markup looks correct, but a sizes attribute that does not match the rendered width makes a phone download the 1600w desktop rendition into a 360px slot. This guide is part of the Image & Media Weight Budgets reference and targets the specific problem of setting and enforcing a byte ceiling per viewport, so each device downloads only the rendition it can actually display. The fix is a per-breakpoint byte budget plus a CI assertion on delivered image bytes, measured under the viewport that selects each rung.

Per-Breakpoint Byte Budget

A responsive byte budget is a ladder, not a single number. Each rung pairs a render width with the byte ceiling for the rendition that fills it, and the sizes attribute is what tells the browser which rung to climb. The table below is a representative AVIF ladder at P75 on a mid-range mobile device over 4G; the sizes column is the load-bearing part most teams get wrong.

Breakpoint Viewport Render width sizes value Byte ceiling (AVIF)
Mobile ≤ 600px 480w 100vw 40 KB
Tablet 601–1024px 960w 100vw 90 KB
Desktop ≥ 1025px 1600w 1600px 180 KB
Retina mobile ≤ 600px @2x 960w 100vw 70 KB

The retina row matters: a 2× mobile device selects the 960w rendition for a 480px slot, so it spends more bytes than a 1× phone but far fewer than a desktop. Budget the rendition the device actually fetches, not the CSS pixel width of the slot.

Diagnostic Steps

  1. Measure delivered image bytes per viewport by collecting under each device's emulation profile and isolating the image resource type.

    npx lighthouse https://staging.example.com \
      --emulated-form-factor=mobile --only-audits=resource-summary --output=json --quiet \
      | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const i=JSON.parse(d).audits['resource-summary'].details.items.find(x=>x.resourceType==='image');console.log('mobile image bytes:',Math.round(i.transferSize/1024),'KB')})"

    Expected output: mobile image bytes: <n> KB — compare against the 150 KB page total ceiling for the mobile viewport.

  2. Confirm the browser picked the right rung by inspecting currentSrc in the console at the target viewport width.

    console.table(
      [...document.images].map((img) => ({
        alt: img.alt,
        rendered: `${img.width}px`,
        chosen: img.currentSrc.split("/").pop(),
      }))
    );

    Expected output: each image shows the rendition matching its rendered width — a 360px slot should report a 480 or 960 file, never 1600.

Implementation

The markup pairs a <picture> for format selection with a srcset/sizes ladder for resolution selection, and the sharp config below emits exactly the widths the ladder references — no orphaned renditions, no missing rungs.

// responsive-encode.js — emit the ladder rungs the sizes attribute references
const sharp = require("sharp");
const RUNGS = [480, 960, 1600]; // mobile, retina-mobile/tablet, desktop

async function build(src, out) {
  for (const w of RUNGS) {
    await sharp(src)
      .resize({ width: w, withoutEnlargement: true })
      .avif({ quality: 50, effort: 4 })
      .toFile(`${out}-${w}.avif`);
    await sharp(src)
      .resize({ width: w, withoutEnlargement: true })
      .webp({ quality: 72 })
      .toFile(`${out}-${w}.webp`);
  }
}

module.exports = { build, RUNGS };
<!-- sizes drives selection: 100vw below 1025px, fixed 1600px above -->
<picture>
  <source type="image/avif"
    srcset="/hero-480.avif 480w, /hero-960.avif 960w, /hero-1600.avif 1600w"
    sizes="(max-width: 1024px) 100vw, 1600px">
  <source type="image/webp"
    srcset="/hero-480.webp 480w, /hero-960.webp 960w, /hero-1600.webp 1600w"
    sizes="(max-width: 1024px) 100vw, 1600px">
  <img src="/hero-960.webp" width="1600" height="900" alt="Product hero"
       loading="lazy" decoding="async">
</picture>

CI Gating Assertion

This lighthouserc.json block fails the build when delivered image bytes exceed the mobile-viewport ceiling and flags any non-responsive or non-modern image, so a wrong sizes value or a missing rendition is caught in the pull request.

{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "settings": { "preset": "perf", "emulatedFormFactor": "mobile" }
    },
    "assert": {
      "assertions": {
        "resource-summary:image:size": ["error", { "maxNumericValue": 153600 }],
        "uses-responsive-images": ["error", { "maxLength": 0 }],
        "modern-image-formats": ["error", { "maxLength": 0 }],
        "efficient-animated-content": ["warn", { "maxLength": 0 }]
      }
    }
  }
}

To gate desktop separately, run a second collection with "emulatedFormFactor": "desktop" and a 500 KB image ceiling, so each viewport asserts against its own budget rather than a single blended number.

Verification

Confirm the ladder is enforced by checking three things. First, the uses-responsive-images audit must report passing — a non-zero length means the browser downloaded a rendition larger than the rendered slot, which is the wrong-sizes bug. Second, run the currentSrc console diagnostic at a 360px viewport and confirm every image reports a 480 or 960 rendition; a 1600 file at that width proves the sizes attribute is mis-set. Third, deliberately point a <source> sizes to 1600px unconditionally, re-run CI, and verify the gate exits non-zero with ✘ resource-summary:image:size failure. A passing responsive audit, correct currentSrc selection at the mobile viewport, and a caught deliberate regression together prove the per-breakpoint budget is enforced rather than merely declared.

Frequently Asked Questions

Why does a phone download the desktop image despite a correct srcset?

Almost always the sizes attribute, not srcset. The browser uses sizes to compute the slot width before layout, and if it claims a wide slot the browser picks the largest rung. Set sizes to the real rendered width per breakpoint — for example (max-width: 1024px) 100vw, 1600px — and verify with the currentSrc diagnostic. The full pipeline is in Image & Media Weight Budgets.

How do I budget for retina (2x) devices without doubling every ceiling?

Budget the rendition the device fetches, not the CSS slot. A 2× phone with a 480px slot selects the 960w rendition, so give it its own ceiling — around 70 KB — between the 1× mobile and tablet rungs. Do not apply the desktop ceiling to retina mobile; the device still has a mobile-sized viewport, which is why this aligns with Mobile vs Desktop Budget Divergence.