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
-
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. -
Confirm the browser picked the right rung by inspecting
currentSrcin 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
480or960file, never1600.
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.