Image & Media Weight Budgets

Images are usually the single largest byte category a page ships and the most common cause of a blown Largest Contentful Paint, yet they are the easiest to leave unbudgeted because a CMS upload bypasses every code review. This is the media-weight layer of the Defining Web Performance Budgets reference: it turns image and video delivery into a budgeted, gated pipeline with per-breakpoint byte ceilings, modern formats, reserved layout boxes, and a CI assertion on resource-summary:image that fails the build when a regression slips through.

The discipline has four moving parts that interact: the format (AVIF or WebP over JPEG), the responsive set (a srcset/sizes pair that ships the right resolution per viewport), the loading strategy (eager for the hero, lazy for everything below the fold), and the layout reservation (an aspect-ratio box that prevents a Cumulative Layout Shift). Get the format and responsive set right and you cut bytes; get loading and reservation right and you protect LCP and CLS. This page is the authoritative spec for all four, and every threshold it names is stated at a percentile and a device-plus-connection context so you can copy a number without guessing what it was measured on.

Architecture Overview

Image bytes are not one budget — they are a budget per viewport, because a phone should never download the desktop hero. The diagram below shows how a single source image fans out through an encoding pipeline into a srcset ladder, and how each rung maps to a byte ceiling for the viewport that selects it.

Image byte-budget allocation by viewport One source image is encoded to AVIF and WebP at mobile, tablet, and desktop widths. Each rendition is assigned a byte ceiling, and the browser selects the correct rung of the srcset ladder based on the viewport, so a mobile device downloads only the small budgeted rendition. Source image Encode AVIF / WebP sharp pipeline Mobile 480w at most 40 KB Tablet 960w at most 90 KB Desktop 1600w at most 180 KB srcset + sizes select
One source image is encoded to AVIF and WebP at three widths, each with its own byte ceiling; the browser's srcset and sizes selection means a mobile device downloads only the small budgeted rendition.

The reason to budget by viewport rather than by page is that the mobile viewport is both the tightest and the most abused. A hero that fits comfortably at 180 KB on a 1600-pixel desktop viewport becomes a 180 KB payload on a phone if the sizes attribute is wrong, and on a mid-range Android device over a 4G link at the 75th percentile that single decode-and-paint can push LCP past 2.5 seconds on its own. The ladder in the diagram exists so the phone downloads the 40 KB rung and the desktop downloads the 180 KB rung from the same markup. Everything below builds the pipeline, the ceilings, and the CI gate that keep those rungs honest.

Prerequisites & Environment

Budgeting media weight requires a build-time encoding step you control and a CI runner that can measure delivered image bytes under emulation. The work assumes first-party budgets and a Lighthouse CI pipeline are already in place.

  • sharp ≥ 0.33 — the encoding pipeline that produces AVIF and WebP renditions deterministically; pin the version so output bytes are reproducible across machines.
  • @lhci/cli ≥ 0.13 — supplies the resource-summary:image assertion used to gate total image bytes.
  • A responsive markup layer — either hand-authored <picture>/srcset or a framework image component (Eleventy Image, Next/Image) that emits the ladder from a single source.
  • An emulation profile — measure delivered bytes against a mid-range mobile device (roughly a Moto G-class phone, 4x CPU throttle) on 4G at P75, the same profile used in Mobile vs Desktop Budget Divergence, because the mobile viewport is where image budgets bite hardest.

Map dynamic values through environment variables so the same workflow runs against any preview origin:

  • STAGING_BASE_URL — the preview origin Lighthouse collects against.
  • IMAGE_CDN_BASE — origin for the encoded renditions, kept on the CSP allowlist.

Pin the sharp version deliberately: encoder defaults change between minor releases, and a bump that shaves two percent off AVIF bytes on one machine can add three percent on the CI runner, which is enough to flip a gate that sits within its variance band. Reproducible bytes are the whole point of a byte budget, so treat the encoder like any other locked dependency.

Configuration Reference

Two artifacts define the media-weight layer: a budget manifest with per-breakpoint ceilings, and a sharp pipeline that produces renditions matching those ceilings. Both are annotated inline.

# image-budget.yml — per-breakpoint ceilings at P75 mobile 4G
formats: [avif, webp, jpeg]   # preference order; jpeg is the fallback
breakpoints:
  - name: mobile
    width: 480
    max_kb: 40                # hero rendition ceiling at this width
  - name: tablet
    width: 960
    max_kb: 90
  - name: desktop
    width: 1600
    max_kb: 180
loading:
  lcp_image: eager            # the above-the-fold hero loads eagerly
  below_fold: lazy            # everything else defers
cls:
  require_dimensions: true    # width/height or aspect-ratio mandatory
// encode.js — sharp pipeline emitting budgeted AVIF/WebP/JPEG renditions
const sharp = require("sharp");
const WIDTHS = [480, 960, 1600];

async function encode(input, outBase) {
  for (const w of WIDTHS) {
    const base = sharp(input).resize({ width: w, withoutEnlargement: true });
    await base.clone().avif({ quality: 50, effort: 4 }).toFile(`${outBase}-${w}.avif`);
    await base.clone().webp({ quality: 72 }).toFile(`${outBase}-${w}.webp`);
    await base.clone().jpeg({ quality: 78, mozjpeg: true }).toFile(`${outBase}-${w}.jpg`);
  }
}

module.exports = { encode, WIDTHS };

AVIF at quality: 50 typically lands 30–50% under the equivalent WebP for photographic content; the withoutEnlargement guard prevents upscaling a small source past its native resolution, which wastes bytes for no visual gain. The effort: 4 setting is a build-time trade-off: raising it to 6 shrinks AVIF output by a few more percent but roughly doubles encode time, which matters when the encode runs inside every CI build rather than once at authoring time.

Choosing the Format Per Asset

Format is the single biggest lever on image bytes, but there is no one winner. AVIF dominates on photographs, ties or loses on flat UI graphics, and is the wrong tool entirely for motion. Rather than mandate one codec, the budget mandates a decision that every asset must pass through, and the <picture> element lets the browser act on that decision at request time. The tree below is the rule the encode pipeline and the markup both encode.

Per-asset format decision tree Each source asset is classified: photographic content is encoded to AVIF with WebP and JPEG fallbacks, animated or motion content becomes a muted video, and flat UI graphics or logos become lossless WebP or PNG-8. Source asset Photo- graphic? Motion / animated? AVIF q45 to 55 plus WebP + JPEG WebP lossless or PNG-8 Muted video WebM + MP4 Yes No No Yes
Route every asset through one decision: photographs to AVIF, motion to muted video, flat UI to lossless WebP or PNG-8 — then let the picture element pick per browser.

The motion branch is the one teams forget. A looping animated GIF is frequently 10x the bytes of the equivalent muted, autoplaying <video> in WebM with an MP4 fallback, and a single 2 MB decorative GIF can eat a whole page's image budget by itself. Treat GIFs as a lint failure in the media pipeline, not a supported format. For flat UI — icons, logos, screenshots with large flat regions and hard edges — AVIF's lossy encoder introduces ringing artifacts and often produces larger files than a well-quantized PNG-8 or a lossless WebP, so the tree routes them away from AVIF deliberately.

Step-by-Step Implementation

  1. Encode the source set so every image has renditions at all three widths in all three formats.

    node -e "require('./encode.js').encode('src/hero.jpg','dist/hero')"
    ls dist/hero-*.{avif,webp,jpg}

    Expected output: nine files — hero-480/960/1600 in .avif, .webp, .jpg.

  2. Author the responsive markup with a <picture> element so the browser selects the smallest format it supports at the right width, and reserve the box with width/height.

    <picture>
      <source type="image/avif" srcset="/hero-480.avif 480w, /hero-960.avif 960w, /hero-1600.avif 1600w" sizes="(max-width: 600px) 100vw, 1600px">
      <source type="image/webp" srcset="/hero-480.webp 480w, /hero-960.webp 960w, /hero-1600.webp 1600w" sizes="(max-width: 600px) 100vw, 1600px">
      <img src="/hero-1600.jpg" width="1600" height="900" alt="Product hero" fetchpriority="high">
    </picture>
  3. Mark below-fold images lazy and verify the delivered bytes per viewport before wiring CI.

    npx lighthouse $STAGING_BASE_URL --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('image bytes:',Math.round(i.transferSize/1024),'KB')})"

    Expected output: image bytes: <n> KB, confirming the total fits the page's image ceiling.

The sizes attribute deserves more care than it usually gets, because it is what turns the srcset ladder from decoration into savings. It tells the browser how wide the image will render before layout runs, so the browser can pick a rung without downloading and measuring. If a hero renders full-bleed on phones but capped at 1200 pixels inside a centered container on desktop, the honest value is (max-width: 600px) 100vw, 1200px — not the raw source width. A sizes that overstates the rendered width is the most common reason a phone downloads the desktop rung and blows its 40 KB ceiling while every file in the ladder is individually within budget.

Threshold Calibration

Do not adopt these numbers blind — derive each ceiling from the rendition sharp actually produces for your imagery, set the lab assertion 10–15% tighter to absorb compression variance, and confirm the percentile methodology against Percentile-Based Threshold Tuning. Values are AVIF renditions at P75 on a mid-range mobile device over 4G.

Breakpoint Render width Per-image ceiling (AVIF) Page image total LCP image priority
Mobile 480w 40 KB 150 KB Eager, fetchpriority="high"
Tablet 960w 90 KB 300 KB Eager
Desktop 1600w 180 KB 500 KB Eager
Below-fold (any) matched same as breakpoint counts to total Lazy

The LCP image must never be lazy-loaded — defer only what is below the fold, and map the hero's byte ceiling directly to your LCP budget in Core Web Vitals Budget Allocation. Every image needs explicit dimensions or an aspect-ratio box; a missing reservation is the most common source of media-driven CLS. The per-breakpoint ceilings get their own deeper treatment in Setting Responsive Image Byte Budgets.

A worked calibration keeps the numbers honest. Encode ten representative heroes, read the actual AVIF bytes at 480w, and take the 90th-percentile file rather than the mean — a mean lets one heavy asset hide behind nine light ones. If that P90 rendition lands at 36 KB, a 40 KB ceiling gives a healthy 10% cushion for the noisy real-world photo you have not shot yet; if it lands at 44 KB, either the ceiling is too tight for your content or quality: 50 is too high for it, and the fix is to lower AVIF quality one notch and re-measure, not to quietly raise the budget. Recalibrate whenever the art direction changes materially, because a switch from clean product shots on white to busy lifestyle photography can move encoded bytes by 40% at the same quality setting.

Protecting the LCP Hero Path

Bytes are only half of LCP. The other half is when those bytes are requested, and a hero that fits its 40 KB ceiling can still paint late if it is discovered slowly, deprioritized, or lazy-loaded by a framework default. The timeline below traces the request order for a well-budgeted page: the HTML arrives, the eager hero is fetched at high priority immediately, LCP fires well inside its budget, and the below-fold gallery defers until after the important paint is done.

Loading order that protects LCP A gantt-style network timeline: the HTML document loads first, the eager hero image with high fetch priority completes near 1.2 seconds so LCP fires at 1.3 seconds inside the 2.5 second budget, and below-fold lazy images defer to after 1.6 seconds. HTML document Hero image (eager) Below-fold (lazy) 0 1 s 2 s 3 s HTML 280 ms AVIF 38 KB deferred LCP 1.3 s Budget 2.5 s
An eager, high-priority hero completes near 1.2 s so LCP fires at 1.3 s — a full 1.2 s inside the 2.5 s P75 budget — while the lazy gallery waits its turn.

Three concrete controls produce that timeline. First, fetchpriority="high" on the hero <img> overrides the browser's default low initial priority for images and lets it compete with scripts and stylesheets for early bandwidth. Second, keeping the hero out of any loading="lazy" path — including framework components that lazy-load by default — guarantees the request is not held back until the layout engine decides the element is near the viewport. Third, avoiding a CSS background-image for the LCP element, because background images are discovered only after the relevant stylesheet parses, which delays the request by exactly the render-blocking CSS time. For interactive apps that swap heroes on client-side navigation, the discovery problem shifts to the router and is treated in Single-Page App Performance Budgets, where the hero for a soft-navigated route is not in the initial HTML at all.

CI Enforcement Snippet

This GitHub Actions job builds, collects Lighthouse runs, and asserts the image resource summary, surfacing a required status check that branch protection can gate on.

name: Image Weight Gate
on:
  pull_request:
    branches: [main]

jobs:
  image-budget:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run build   # runs encode.js as part of the build
      - name: Assert image budgets
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

The matching lighthouserc.json caps image bytes and warns on the LCP and CLS metrics that media weight drives:

{
  "ci": {
    "collect": { "numberOfRuns": 3, "settings": { "preset": "perf" } },
    "assert": {
      "assertions": {
        "resource-summary:image:size": ["error", { "maxNumericValue": 153600 }],
        "modern-image-formats": ["error", { "maxLength": 0 }],
        "uses-responsive-images": ["error", { "maxLength": 0 }],
        "efficient-animated-content": ["error", { "maxLength": 0 }],
        "largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

The 153600-byte ceiling targets the mobile viewport; scale it per matrix entry when you fan collection across viewports, and keep this gate distinct from the Web Font Performance Budgets check so a font and an image regression fail with different messages. The modern-image-formats and uses-responsive-images opportunity audits are set to hard-fail at maxLength: 0, which means a single JPEG-only image or a missing srcset breaks the build — that is deliberate, because those two omissions are exactly how a well-budgeted page silently regresses. The efficient-animated-content audit at maxLength: 0 enforces the motion branch of the format tree by failing any animated GIF that should have been a video. Because scripts and images compete for the same early bandwidth, pair this gate with JavaScript Bundle Size Limits so a script regression cannot starve the hero request and inflate LCP even when the image bytes are within budget.

Troubleshooting & Edge Cases

  • AVIF larger than WebP for flat graphics → AVIF wins on photographs but loses on simple logos and screenshots; let the <picture> source order pick per-asset, or ship PNG/WebP for non-photographic content.
  • CMS upload bypasses the budget → run encode.js on upload via a hook or build step so editor-supplied images enter the same pipeline; an unbudgeted CMS path is the classic regression that no code review will catch.
  • LCP image lazy-loaded by a framework default → many image components lazy-load everything; explicitly set the hero to eager with fetchpriority="high" or LCP will regress despite the bytes fitting.
  • Layout shift despite dimensions → a responsive image without an aspect-ratio CSS rule still shifts on slow connections; reserve the box in CSS, not just the width/height attributes.
  • sizes mismatch ships the wrong rendition → an inaccurate sizes attribute makes the browser download a 1600w image into a 480px slot; align sizes to the real rendered width per breakpoint.
  • Animated GIFs blow the budget → convert GIFs to muted autoplay <video> (WebM/MP4); a looping GIF is often 10x the bytes of the equivalent video, and efficient-animated-content in CI will catch any that slip through.
  • Retina DPR doubles the payload → a 2x device requests the next rung up, so a 480px slot on a high-DPI phone pulls the 960w file; budget the rung the device actually selects, not the CSS pixel width, or the P75 mobile total will read high in the field.
  • Third-party embeds ship unbudgeted images → a social embed or map tile arrives outside your pipeline; account for its bytes in the page total or block it behind a facade that loads only on interaction.

Frequently Asked Questions

Should I budget image bytes per breakpoint or per page?

Both, but the per-breakpoint ceiling is the load-bearing one. A single page total hides that a phone is downloading a desktop rendition, so set a ceiling for each viewport's rendition and a page total that the breakpoints roll up into. The CI gate asserts the page total under resource-summary:image:size at the mobile viewport, which is where the budget bites hardest. The per-breakpoint method is detailed in Setting Responsive Image Byte Budgets.

How do images cause layout shift, and how do I budget against it?

An image with no reserved box collapses to zero height until it loads, then pushes content down — that displacement is Cumulative Layout Shift. Reserve the box with width/height attributes plus an aspect-ratio CSS rule, and assert cumulative-layout-shift at 0.1 in CI at the P75 mobile profile. Map the budget to Core Web Vitals Budget Allocation.

Is AVIF always the right format to budget for?

For photographic content, yes — AVIF typically lands 30 to 50 percent under WebP at equivalent quality. For flat graphics, logos, and screenshots it can be larger, so keep a <picture> with AVIF, WebP, and a fallback, and let the browser pick. Budget against the AVIF rendition since that is what most modern browsers download.

Why must the LCP hero never be lazy-loaded?

Lazy-loading defers the request until the layout engine decides the element is near the viewport, which for an above-the-fold hero adds hundreds of milliseconds of pure delay on a mid-range phone at P75. The hero should load eagerly with fetchpriority="high" so it competes for early bandwidth; only below-fold images should defer. A framework that lazy-loads every image by default is the most common cause of a hero that fits its byte ceiling yet still misses the 2.5 second LCP budget.

How should I handle animated GIFs and video in the same budget?

Convert animated GIFs to a muted autoplay <video> in WebM with an MP4 fallback, because a looping GIF is often ten times the bytes of the equivalent video. Enforce it with the efficient-animated-content Lighthouse audit at maxLength: 0 so any GIF that should have been a video fails the build. Count decorative video bytes toward the page media total at the P75 mobile profile, and load anything non-essential behind a click-to-play facade.