DEV Community

Muhaymin Bin Mehmood
Muhaymin Bin Mehmood

Posted on Fully Autonomous

Find Oversized Images on Any Page With One Console Snippet

Lighthouse will tell you to "properly size images". It won't always tell you which image, by how much, or why it matters on a phone but not on your laptop.

You can get that answer yourself in about 40 lines of JavaScript, pasted into the DevTools console. No extension, no build step.

The idea is simple. For every image on the page, compare:

  1. how many pixels the file has (naturalWidth)
  2. how many pixels the screen can actually use for it (the rendered box, times devicePixelRatio)

If the file is three times wider than what the screen can use, the visitor downloaded and decoded roughly nine times the pixels they needed.

The snippet

(() => {
  const dpr = window.devicePixelRatio || 1;

  // Transfer sizes from Resource Timing, keyed by URL
  const bytesByUrl = new Map(
    performance.getEntriesByType("resource").map((e) => [e.name, e.encodedBodySize])
  );

  const rows = [];

  for (const img of document.images) {
    const src = img.currentSrc || img.src;
    if (!src || src.startsWith("data:") || /\.svg(\?|#|$)/i.test(src)) continue;

    const box = img.getBoundingClientRect();
    if (box.width === 0 || box.height === 0) continue; // hidden or display:none

    if (!img.complete || img.naturalWidth === 0) {
      rows.push({ image: src.split("/").pop().slice(0, 40), status: "not loaded yet (lazy?)" });
      continue;
    }

    // How much the browser scales the file to fill the box.
    // object-fit: cover fills the box and crops, so the larger scale wins.
    // Everything else we treat as contain-like (the smaller scale wins).
    const sx = box.width / img.naturalWidth;
    const sy = box.height / img.naturalHeight;
    const fit = getComputedStyle(img).objectFit;
    const scale = fit === "cover" ? Math.max(sx, sy) : Math.min(sx, sy);

    // Pixels the screen can actually use, at this device pixel ratio
    const neededW = Math.ceil(img.naturalWidth * scale * dpr);
    const extra = img.naturalWidth / neededW; // 1 = right-sized, 2 = twice as wide as needed

    const bytes = bytesByUrl.get(src);
    rows.push({
      image: src.split("/").pop().slice(0, 40),
      file: `${img.naturalWidth}x${img.naturalHeight}`,
      shown: `${Math.round(box.width)}x${Math.round(box.height)}`,
      needed: `${neededW}px wide @${dpr}x`,
      extra: +extra.toFixed(1),
      kb: bytes ? Math.round(bytes / 1024) : bytes === 0 ? "hidden" : "unknown",
    });
  }

  rows.sort((a, b) => (b.extra ?? 0) - (a.extra ?? 0));
  console.table(rows);
})();
Enter fullscreen mode Exit fullscreen mode

Paste it into the console on any page and you get a table sorted by the worst offenders. extra is the column to read. Anything around 1 is fine. Anything above 2 is worth a look.

Three details that make the numbers honest

1. currentSrc, not src

If the image uses srcset, the browser picks one candidate and img.src still points at the fallback. currentSrc is the file that was actually downloaded, so that is the one to measure and the one to look up in Resource Timing.

2. object-fit: cover changes the math

I wrote about cover vs contain before, and it matters here too. A wide hero image inside a short, wide box with object-fit: cover gets scaled until it fills the box on both axes, and the overflow is cropped. So the scale is the larger of the two ratios, not the smaller one.

If you get this wrong, a cropped hero image looks massively oversized when it isn't, or the other way round.

3. Device pixel ratio

On a laptop at DPR 1, a 400px box needs a 400px file. On a phone at DPR 3, the same 400px box can use 1200px. An image that looks oversized on your desktop might be exactly right on a phone.

So run it at the sizes your visitors actually use. In DevTools, switch on device mode, pick a phone preset (that sets the DPR too), reload, then run the snippet again.

What kb can and can't tell you

The kb column comes from encodedBodySize in the Resource Timing API. Two catches:

  • Cross-origin images report 0 unless the image server sends a Timing-Allow-Origin header. The snippet shows those as hidden, not as 0 KB, because "the browser won't tell me" is not the same as "it's free". Many image CDNs do send the header, so you will often get real numbers.
  • The timing buffer has a limit. Browsers keep 250 resource entries by default. On a very heavy page, older entries may be missing and show up as unknown. Calling performance.setResourceTimingBufferSize(1000) early in the page load raises the limit, but that's for your own site, not for one you're inspecting.

Is the LCP image one of them?

An oversized thumbnail in the footer costs bandwidth. An oversized LCP image costs you the metric Google actually looks at. This one-liner tells you which element was the Largest Contentful Paint:

new PerformanceObserver((list) => {
  const lcp = list.getEntries().at(-1);
  console.log("LCP:", lcp.element, lcp.url, Math.round(lcp.startTime) + "ms");
}).observe({ type: "largest-contentful-paint", buffered: true });
Enter fullscreen mode Exit fullscreen mode

buffered: true matters, because LCP has usually already happened by the time you open the console. Without it you get nothing.

If the URL it prints is near the top of the extra table, that is the first image to fix.

What I found when I tested it

I ran both snippets on the homepage of a large, well-known e-commerce brand, at a 765px-wide viewport and DPR 1:

  • 42 images on the page.
  • The LCP element was the hero image: a 2560x1442 JPEG, about 559 KB, shown in a 765x235 box with object-fit: cover. That's 3.3x wider than the viewport could use, which works out to around 11 times the pixels.
  • Eight category tiles were 696x920 files shown at 184x239, so 3.8x wider than needed. Each was only 30 to 42 KB, so they hurt far less than the hero did.

That last point is why the kb column is there. extra tells you an image is too big. kb tells you whether it's worth your afternoon.

To be fair to that site, it was a narrow viewport at DPR 1, which is the worst case for this test. At full desktop width some of these would come out much closer to right-sized. That's exactly why you should test at more than one size.

Fixing what it finds

  • Serve smaller files where the box is small. srcset with width descriptors plus an honest sizes attribute lets the browser pick. If sizes is missing, the browser assumes 100vw and will happily pick the largest candidate for a thumbnail.
  • Don't fix the hero with lazy loading. The LCP image should load early: no loading="lazy", and consider fetchpriority="high".
  • Resize at the source when the CMS only stores one huge original. If you have a folder of product photos to bring down to a sane width, I built a free bulk image resizer that does it in the browser without uploading the files.

Limits of a console snippet

This only sees <img> elements. CSS background-image, images inside shadow DOM and canvas drawings aren't covered. It also only sees images that have loaded, so scroll the page first if it uses lazy loading. And carousels often clone slides, so the same file can appear several times in the table.

If you want this as a report instead of a console table, with transfer sizes measured from the server side and a list of fixes, I also built a website image auditor that scans a public page. The snippet above is the quick version you can run anywhere, including on staging and behind a login.

Either way, start with the LCP image. It's usually one file, and it's usually the one that matters.

This post was drafted with an AI assistant. Both snippets were run in a real browser before publishing.

Top comments (0)