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.
Byte budgets for images are not cosmetic. On a mid-range Android phone (Moto G-class) over Fast 3G, every 100 KB of extra image transfer adds roughly 250–400 ms to the largest contentful paint at P75, because the hero image is usually the LCP element and it competes with render-blocking CSS for the first RTTs. A ladder that quietly ships desktop bytes to phones is the single largest source of LCP budget overrun we see, so it deserves its own enforced ceiling rather than a share of a blended page total.
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. The ceilings themselves come from a percentile decision, not a guess — set each rung at the P75 encoded size for a representative photographic image at that width, then leave a small buffer for the occasional busy frame so the gate does not flap on a slightly noisier hero. The chart below plots the four ceilings so the shape of the ladder is obvious: the desktop rung is over four times the mobile rung, which is exactly why a mis-set sizes value is so expensive on a phone.
How the Browser Picks a Rung
Selection happens before layout. When the parser reaches the <img>, it evaluates sizes against the current viewport to compute a slot width in CSS pixels, multiplies by the device pixel ratio to get a target in device pixels, and then picks the smallest srcset candidate whose width is at least that target. Crucially, the browser never measures the real rendered box — it trusts sizes completely. If sizes claims 1600px on a 360px phone, the computed target is 1600 device pixels and the 1600w rung wins, regardless of how narrow the element actually renders. This is why the sizes attribute, not srcset, is where nearly every over-download originates.
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 at P75 on a mid-range phone over 4G. -
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. -
Attribute the bytes to a real device, not a lab default. Lighthouse emulates a fixed DPR, but your field traffic does not. Pull the P75 device pixel ratio and viewport width from your real-user data before trusting a single lab number; the method for turning field samples into a defensible ceiling lives in Percentile-Based Threshold Tuning. If 40% of your mobile P75 traffic is DPR 3, the retina rung — not the 1× rung — sets your effective mobile ceiling.
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>
Two details in that markup are easy to drop and expensive to lose. The explicit width and height on the <img> give the browser an aspect ratio to reserve space, which keeps a lazy-loaded hero from shifting layout and blowing the cumulative layout shift budget. And the loading="lazy" attribute belongs on below-the-fold images only — putting it on the LCP hero delays its fetch and pushes LCP past the 2500 ms P75 target for a mid-range phone. Above the fold, prefer fetchpriority="high" and eager loading.
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 at P75, so each viewport asserts against its own budget rather than a single blended number. Blending the two into one average is the classic mistake: a desktop rung well under 500 KB can mask a mobile rung that is double its 150 KB ceiling, and the build stays green while phones suffer. Keep the two assertions on independent collections so a mobile regression cannot be averaged away by a healthy desktop.
Common Failure Modes
Beyond the wrong-sizes bug, three patterns recur. First, a CSS background-image hero escapes both srcset and the uses-responsive-images audit entirely — it always ships one fixed rendition to every device, so move any LCP-eligible art into a real <picture> element. Second, an art-directed crop that swaps aspect ratio between mobile and desktop needs multiple <source media="..."> branches, and each branch needs its own ceiling; a single blended budget will be wrong for both. Third, on a single-page app the hero often mounts after a client-side route change rather than on first paint, so the initial Lighthouse run never sees it — gate those transitions with the approach in Single-Page App Performance Budgets instead of assuming the first navigation covers every hero.
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.
What percentile and device should the byte ceiling be set at?
Set each rung at the P75 encoded size for a representative photographic image at that width, measured for the device class that actually selects the rung — a mid-range Android over 4G for the mobile rungs, a laptop over cable for desktop. Leave a small headroom buffer so a slightly busier hero does not flap the gate, and revisit the number when your field DPR mix shifts.
Should I gate mobile and desktop image bytes together or separately?
Separately, on two independent Lighthouse collections. A single blended average lets a healthy desktop rung mask a mobile rung that is double its 150 KB P75 ceiling, so the build stays green while phones over-download. Run one collection per emulated form factor and assert each against its own ceiling.
Does a CSS background-image hero count against this budget?
It ships bytes but escapes srcset selection and the uses-responsive-images audit, so it always sends one fixed rendition to every device. If it is the LCP element, move it into a real <picture> so the ladder applies; otherwise it will quietly blow the mobile ceiling on every phone.