# Oversized images

> Source: <https://webperf-snippets.net/Media/Oversized-Images>

Compares the pixels of each downloaded image file with the pixels the page draws it at (rendered size × `devicePixelRatio`), and reports the images that are much larger than needed, which waste bytes, and the images that are smaller than needed, which look blurry.

### What the snippet checks

| Check                                                  | Scope                        |
| ------------------------------------------------------ | ---------------------------- |
| File at least 1.5x larger than the slot on each axis   | Loaded `<img>` elements      |
| File under 90% of the pixels the slot needs (blurry)   | Loaded `<img>` elements      |
| Wasted pixels and estimated wasted bytes               | Oversized images             |
| The file chosen through `srcset` and `sizes`           | `currentSrc` of each image   |
| `object-fit` scaling (`cover`, `contain`, `none`, ...) | Images with a fixed-size box |

### Snippet

```js
// Oversized Images
// https://webperf-snippets.net

(async () => {
  // @shared ownRequestUrl
  // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
  // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
  // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
  // which is how isOwnRequest leaves these requests out of what the page loaded.
  function ownRequestUrl(url) {
    try {
      const parsed = new URL(url, location.href);
      if (!/^https?:$/.test(parsed.protocol)) return url;
      parsed.hash = "webperf-snippets";
      return parsed.href;
    } catch {
      return url;
    }
  }
  // @end-shared ownRequestUrl

  // @shared isOwnRequest
  // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
  // did not make it, so no snippet counts it.
  function isOwnRequest(entry) {
    return entry.name.endsWith("#webperf-snippets");
  }
  // @end-shared isOwnRequest

  const OVERSIZED_RATIO = 1.5; // file has at least 1.5x the pixels it needs on each axis
  const UNDERSIZED_RATIO = 0.9; // file has less than 90% of the pixels it needs
  const SEVERE_OVERSIZED = 2;
  const SEVERE_UNDERSIZED = 0.75;
  const MAX_ITEMS = 50;
  const LOAD_TIMEOUT_MS = 3000;

  const dpr = window.devicePixelRatio || 1;

  const round2 = (n) => Math.round(n * 100) / 100;
  const px = (v) => parseFloat(v) || 0;

  const shortUrl = (url) => (url.startsWith("data:") ? url.slice(0, 60) : url);
  const fileName = (url) => url.split("?")[0].split("/").pop() || url;

  function selectorOf(el) {
    if (el.id) return `img#${el.id}`;
    const cls = Array.from(el.classList).slice(0, 2);
    return cls.length ? `img.${cls.join(".")}` : "img";
  }

  function isVector(url) {
    return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url);
  }

  // The size of the file in pixels. HTMLImageElement.naturalWidth is corrected by the srcset
  // density (a 400px file picked as "2x" reports 200), so a fresh Image, which has no srcset,
  // gives the real pixels. The file comes from the browser cache.
  function loadPixels(url) {
    return new Promise((resolve) => {
      const probe = new Image();
      const timer = setTimeout(() => resolve(null), LOAD_TIMEOUT_MS);
      probe.onload = () => {
        clearTimeout(timer);
        resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
      };
      probe.onerror = () => {
        clearTimeout(timer);
        resolve(null);
      };
      probe.src = ownRequestUrl(url);
    });
  }

  // Bytes of each image URL; null when the browser hides them (cross-origin without
  // Timing-Allow-Origin).
  const transferByUrl = new Map();
  performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e)).forEach((e) => {
    if (e.initiatorType !== "img" && e.initiatorType !== "css" && e.initiatorType !== "other") return;
    const bytes = e.transferSize > 0 ? e.transferSize : e.encodedBodySize > 0 ? e.encodedBodySize : null;
    if (bytes !== null) transferByUrl.set(e.name, bytes);
  });

  // Scale from file pixels to CSS pixels on each axis, following object-fit.
  function scaleOf(img, boxW, boxH, fileW, fileH) {
    const fit = getComputedStyle(img).objectFit;
    const sx = boxW / fileW;
    const sy = boxH / fileH;
    const contain = Math.min(sx, sy);
    const none = img.naturalWidth / fileW; // CSS pixels per file pixel, density-corrected
    if (fit === "cover") return { sx: Math.max(sx, sy), sy: Math.max(sx, sy) };
    if (fit === "contain") return { sx: contain, sy: contain };
    if (fit === "none") return { sx: none, sy: none };
    if (fit === "scale-down") {
      const s = Math.min(contain, none);
      return { sx: s, sy: s };
    }
    return { sx, sy }; // fill
  }

  const images = Array.from(document.querySelectorAll("img"));
  let skippedHidden = 0;
  let skippedNotLoaded = 0;
  let skippedVector = 0;

  const candidates = [];
  images.forEach((img) => {
    const rect = img.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) return void skippedHidden++;
    const url = img.currentSrc || img.src || "";
    if (!img.complete || img.naturalWidth === 0 || !url) return void skippedNotLoaded++;
    if (isVector(url)) return void skippedVector++;
    candidates.push({ img, rect, url });
  });

  const pixelsByUrl = new Map();
  await Promise.all(
    [...new Set(candidates.map((c) => c.url))].map(async (url) => {
      pixelsByUrl.set(url, await loadPixels(url));
    }),
  );

  const flagged = [];
  let checked = 0;
  let wastedPixels = 0;
  const wastedBytesByUrl = new Map();

  candidates.forEach(({ img, rect, url }) => {
    const file = pixelsByUrl.get(url);
    if (!file || !file.width || !file.height) return void skippedNotLoaded++;
    checked++;

    const style = getComputedStyle(img);
    const boxW = rect.width - px(style.paddingLeft) - px(style.paddingRight) - px(style.borderLeftWidth) - px(style.borderRightWidth);
    const boxH = rect.height - px(style.paddingTop) - px(style.paddingBottom) - px(style.borderTopWidth) - px(style.borderBottomWidth);
    if (boxW <= 0 || boxH <= 0) return;

    const { sx, sy } = scaleOf(img, boxW, boxH, file.width, file.height);
    const neededWidth = Math.round(file.width * sx * dpr);
    const neededHeight = Math.round(file.height * sy * dpr);
    // Pixels of the file per device pixel drawn, on the tighter axis
    const ratio = Math.min(1 / (sx * dpr), 1 / (sy * dpr));

    let verdict = null;
    if (ratio >= OVERSIZED_RATIO) verdict = "oversized";
    else if (ratio < UNDERSIZED_RATIO) verdict = "undersized";
    if (!verdict) return;

    const filePixels = file.width * file.height;
    const wasted = verdict === "oversized" ? Math.max(0, filePixels - neededWidth * neededHeight) : 0;
    const transferBytes = transferByUrl.get(url) ?? null;
    const wastedBytes = transferBytes === null ? null : Math.round(transferBytes * (wasted / filePixels));

    wastedPixels += wasted;
    if (wastedBytes !== null) wastedBytesByUrl.set(url, Math.max(wastedBytesByUrl.get(url) ?? 0, wastedBytes));

    flagged.push({
      selector: selectorOf(img),
      url: shortUrl(url),
      verdict,
      naturalWidth: file.width,
      naturalHeight: file.height,
      renderedWidth: Math.round(boxW),
      renderedHeight: Math.round(boxH),
      neededWidth,
      neededHeight,
      ratio: round2(ratio),
      wastedPixels: wasted,
      transferBytes,
      wastedBytes,
      hasSrcset: img.hasAttribute("srcset"),
      hasSizes: img.hasAttribute("sizes"),
    });
  });

  // Most waste first; undersized images (no waste) follow, the blurriest first
  flagged.sort((a, b) => b.wastedPixels - a.wastedPixels || a.ratio - b.ratio);

  const oversized = flagged.filter((f) => f.verdict === "oversized").length;
  const undersized = flagged.length - oversized;
  // An image used several times downloads once, so each URL counts once
  const wastedBytes = [...wastedBytesByUrl.values()].reduce((a, b) => a + b, 0);

  const issues = flagged.map((f) => {
    const size = `${f.naturalWidth}x${f.naturalHeight}`;
    const shown = `${f.neededWidth}x${f.neededHeight}`;
    if (f.verdict === "oversized") {
      const kb = f.wastedBytes === null ? "" : `, about ${Math.round(f.wastedBytes / 1024)} KB wasted`;
      return {
        severity: f.ratio >= SEVERE_OVERSIZED ? "warning" : "info",
        message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (${f.ratio}x too large${kb})`,
      };
    }
    return {
      severity: f.ratio < SEVERE_UNDERSIZED ? "warning" : "info",
      message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (blurry, needs ${round2(1 / f.ratio)}x more pixels)`,
    };
  });

  const items = flagged.slice(0, MAX_ITEMS);

  console.group("%cOversized Images", "font-weight: bold; font-size: 14px;");
  console.log(`   Images checked : ${checked} (devicePixelRatio ${dpr})`);
  console.log(`   Oversized      : ${oversized}`);
  console.log(`   Undersized     : ${undersized}`);
  console.log(`   Wasted pixels  : ${wastedPixels.toLocaleString()}`);
  console.log(`   Wasted bytes   : ${Math.round(wastedBytes / 1024)} KB (estimated)`);
  if (items.length > 0) {
    console.table(
      items.map((f) => ({
        image: fileName(f.url).slice(0, 40),
        verdict: f.verdict,
        file: `${f.naturalWidth}x${f.naturalHeight}`,
        needed: `${f.neededWidth}x${f.neededHeight}`,
        ratio: f.ratio,
        "wasted KB": f.wastedBytes === null ? "n/a" : Math.round(f.wastedBytes / 1024),
      })),
    );
  } else {
    console.log("%cNo oversized or undersized images found.", "color: #22c55e; font-weight: bold;");
  }
  console.groupEnd();

  return {
    script: "Oversized-Images",
    status: "ok",
    count: flagged.length,
    details: {
      devicePixelRatio: dpr,
      totalImages: images.length,
      imagesChecked: checked,
      oversized,
      undersized,
      wastedPixels,
      wastedBytes,
      skippedHidden,
      skippedNotLoaded,
      skippedVector,
    },
    items,
    issues,
  };
})();
```

### Understanding the results

The console shows a summary and a table of the worst offenders. The returned object has the same data in a structured form.

#### How the size is computed

- The file size in pixels is read from the file itself, not from `naturalWidth`, which the browser divides by the `srcset` density (a 400px file chosen as `2x` reports 200).
- The needed size is the content box of the element times `devicePixelRatio`, scaled by `object-fit`. A `cover` image needs to cover the box, a `contain` image needs to fit inside it.
- `ratio` is the pixels of the file per pixel drawn, on the tighter axis. Above 1.5 the image is `oversized`; below 0.9 it is `undersized`.

#### Estimated wasted bytes

`wastedBytes` is the transfer size multiplied by the share of the pixels that are not needed. It is an estimate: compression does not scale linearly with pixels, so a resized file is usually a little larger than the estimate suggests. The transfer size comes from the Resource Timing API, so it is `null` for a cross-origin image served without `Timing-Allow-Origin`. An image used several times counts once in the total.

#### What is skipped

Images with a zero-size box (`display: none`, or `width: 0`), images that have not loaded yet (for example, a `loading="lazy"` image far below the fold) and SVG images are not measured. `details` counts each group. CSS background images are not inspected.

> **Warning:** Scroll the page to the end before running the snippet, or images with `loading="lazy"` are not measured.

#### Fixing an oversized image

- Serve several widths with `srcset` and describe the slot with an accurate `sizes` attribute; a wrong `sizes` (for example `100vw` for a small thumbnail) makes the browser choose a large file.
- Resize at build time or with an image CDN instead of scaling in CSS.
- For an undersized image, serve a file with at least the needed pixels, and add a `2x` candidate for high-density screens.

The result depends on the viewport and on `devicePixelRatio`. Run it in the device mode you care about.

### Browser support

| Feature                                                                                                                     | Chrome | Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [PerformanceResourceTiming.transferSize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/transferSize) |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| [HTMLImageElement.currentSrc](https://developer.mozilla.org/docs/Web/API/HTMLImageElement/currentSrc)                       |  ✅ 38  | ✅ 13 |   ✅ 38  |  ✅ 9.1 |
| All of the above                                                                                                            |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Serve images with correct dimensions](https://web.dev/articles/serve-images-with-correct-dimensions) | web.dev
- [Responsive images](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Structuring_content/HTML_images#responsive_images) | MDN
- [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) | MDN
- [Image Element Audit](https://webperf-snippets.net/Media/Image-Element-Audit.md) | webperf-snippets
