How to find broken images on a website

Let the page finish loading, then run [...document.images].filter(i => i.complete && i.naturalWidth === 0) in the Console. On a four-image test page it returned two: one image that answered 404, and one that answered 200 with an HTML error body. Only the first reached the console.

Why check this

Images break on the same days as everything else that has a path: an asset pipeline rename, a media library migration, a CDN origin change, a bulk product import. Run this after any of those, and on staging before sign-off.

The failure it catches is the silent one. A catalogue import renames every product photo, the grid still renders, alt text fills the gaps, no script throws, and every functional test passes because the assertions are about text. The page ships with empty boxes where the products were.

Prerequisites

import { createServer } from 'node:http';
import { deflateSync, crc32 } from 'node:zlib';

// A real PNG, built here so the page needs no binary files.
const chunk = (type, data) => {
  const t = Buffer.from(type), len = Buffer.alloc(4), crc = Buffer.alloc(4);
  len.writeUInt32BE(data.length);
  crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
  return Buffer.concat([len, t, data, crc]);
};
const png = (w, h, rgb) => {
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
  const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => rgb).flat())]);
  const raw = Buffer.concat(Array.from({ length: h }, () => row));
  return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
    chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
};

const page = `<!doctype html><html lang="en"><meta charset="utf-8"><title>Image test</title>
<img id="hero" src="/img/hero.png" width="240" height="120" alt="Hero">
<img id="logo" src="/img/logo-v2.png" width="240" height="120" alt="Logo">
<img id="banner" src="/img/banner.png" width="240" height="120" alt="Banner">
<div style="height:2000px"></div>
<img id="footer" src="/img/footer.png" loading="lazy" width="240" height="120" alt="Footer">`;

createServer((req, res) => {
  const image = { 'content-type': 'image/png' };
  switch (req.url) {
    case '/': return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(page);
    case '/img/hero.png': return res.writeHead(200, image).end(png(240, 120, [16, 120, 110]));
    case '/img/footer.png': return res.writeHead(200, image).end(png(240, 120, [180, 83, 9]));
    // 200, but the body is an HTML error page, not a PNG.
    case '/img/banner.png':
      return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
        .end('<!doctype html><title>Error</title><h1>Asset temporarily unavailable</h1>');
    // Deleted in a rename. The markup still points at it.
    default: return res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' })
      .end('<!doctype html><title>Not found</title><h1>404</h1>');
  }
}).listen(8732, () => console.log('image test site on http://localhost:8732/'));

Steps

  1. Step 1.

    Start the page under test. Stop it with Ctrl-C at the end.

    node images-server.mjs
    
    image test site on http://localhost:8732/

    Open http://localhost:8732/ in Chrome and press F12. The next steps read the same page from a script so the result can go into CI.

  2. Step 2.

    List the images that finished loading and produced nothing. Type the expression into the Console, or save the script as broken-images.mjs and run it.

    // broken-images.mjs   run: node broken-images.mjs
    import { launch } from 'puppeteer-core';
    const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
    const browser = await launch({ executablePath: CHROME, headless: true });
    const page = await browser.newPage();
    await page.goto('http://localhost:8732/', { waitUntil: 'networkidle2' });
    const broken = await page.evaluate(() =>
      [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.currentSrc || i.src));
    console.log(JSON.stringify(broken, null, 2));
    await browser.close();
    
    [
    "http://localhost:8732/img/logo-v2.png",
    "http://localhost:8732/img/banner.png"
    ]

    Two of the four images are broken. complete alone says the browser stopped working on the image, whether it succeeded or failed, so the naturalWidth === 0 half carries the verdict.

  3. Step 3.

    Read all three signals side by side, with the console and the network list from the same load. Save as image-signals.mjs.

    // image-signals.mjs   run: node image-signals.mjs
    import { launch } from 'puppeteer-core';
    const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
    const browser = await launch({ executablePath: CHROME, headless: true });
    const page = await browser.newPage();
    const log = [];
    page.on('console', (m) => log.push(`${m.type()}: ${m.text()}`));
    const seen = [];
    page.on('response', (r) => seen.push(`${r.status()} ${r.request().resourceType()} ${r.url()}`));
    await page.goto('http://localhost:8732/', { waitUntil: 'networkidle2' });
    const rows = await page.evaluate(() => [...document.images].map((i) =>
      `${i.id.padEnd(7)} complete=${String(i.complete).padEnd(5)} naturalWidth=${String(i.naturalWidth).padEnd(4)} loading=${i.loading}`));
    rows.forEach((r) => console.log(r));
    console.log('--- console messages');
    log.forEach((l) => console.log(l));
    console.log('--- responses');
    seen.forEach((s) => console.log(s));
    await browser.close();
    
    hero    complete=true  naturalWidth=240  loading=auto
    logo    complete=true  naturalWidth=0    loading=auto
    banner  complete=true  naturalWidth=0    loading=auto
    footer  complete=false naturalWidth=0    loading=lazy
    --- console messages
    error: Failed to load resource: the server responded with a status of 404 (Not Found)
    error: Failed to load resource: the server responded with a status of 404 (Not Found)
    --- responses
    200 document http://localhost:8732/
    200 image http://localhost:8732/img/hero.png
    404 image http://localhost:8732/img/logo-v2.png
    200 image http://localhost:8732/img/banner.png
    404 other http://localhost:8732/favicon.ico

    Line by line: hero decoded, logo and banner are complete with a natural width of zero, footer has not started. The console shows two errors and neither names a URL. One of the two is /favicon.ico, which the markup never requested. banner produced no console message at all, because from the network's point of view nothing failed.

  4. Step 4.

    Separate the lazy image from the broken ones by scrolling and reading again. Save as lazy-recheck.mjs.

    // lazy-recheck.mjs   run: node lazy-recheck.mjs
    import { launch } from 'puppeteer-core';
    const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
    const browser = await launch({ executablePath: CHROME, headless: true });
    const page = await browser.newPage();
    await page.goto('http://localhost:8732/', { waitUntil: 'networkidle2' });
    const read = () => page.evaluate(() => [...document.images].map((i) =>
      `${i.id.padEnd(7)} complete=${String(i.complete).padEnd(5)} naturalWidth=${i.naturalWidth}`));
    console.log('at load');
    (await read()).forEach((r) => console.log('  ' + r));
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
    await new Promise((r) => setTimeout(r, 1000));
    console.log('after scrolling to the bottom');
    (await read()).forEach((r) => console.log('  ' + r));
    await browser.close();
    
    at load
    hero    complete=true  naturalWidth=240
    logo    complete=true  naturalWidth=0
    banner  complete=true  naturalWidth=0
    footer  complete=false naturalWidth=0
    after scrolling to the bottom
    hero    complete=true  naturalWidth=240
    logo    complete=true  naturalWidth=0
    banner  complete=true  naturalWidth=0
    footer  complete=true  naturalWidth=240

    footer now has a natural width of 240. It was never broken, it had not been asked for. A filter that only tests naturalWidth === 0 reports it, which is why the complete half of the pair matters.

  5. Step 5.

    Check what decode() reports, since it looks like the tidier test. Save as decode-test.mjs.

    // decode-test.mjs   run: node decode-test.mjs
    import { launch } from 'puppeteer-core';
    const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
    const browser = await launch({ executablePath: CHROME, headless: true });
    const page = await browser.newPage();
    await page.goto('http://localhost:8732/', { waitUntil: 'networkidle2' });
    const out = await page.evaluate(async () => {
      const timeout = (ms) => new Promise((r) => setTimeout(() => r(`still pending after ${ms} ms`), ms));
      const rows = [];
      for (const i of document.images) {
        rows.push(`${i.id.padEnd(7)} ${await Promise.race([
          i.decode().then(() => 'decode resolved', (e) => 'decode rejected: ' + e.name), timeout(3000)])}`);
      }
      return rows;
    });
    out.forEach((r) => console.log(r));
    await browser.close();
    
    hero    decode resolved
    logo    decode rejected: EncodingError
    banner  decode rejected: EncodingError
    footer  still pending after 3000 ms

    decode() names both failures with an EncodingError, and it never settles for the lazy image. Written without the race above, that loop hangs on footer and the script produces no output at all.

  6. Step 6.

    Confirm on the wire what the browser inferred.

    for p in /img/hero.png /img/logo-v2.png /img/banner.png /img/footer.png; do printf '%-18s ' "$p"; curl -s -o /dev/null -w '%{http_code}  %{content_type}  %{size_download} bytes\n' "http://localhost:8732$p"; done
    
    /img/hero.png      200  image/png  405 bytes
    /img/logo-v2.png   404  text/html; charset=utf-8  51 bytes
    /img/banner.png    200  text/html; charset=utf-8  73 bytes
    /img/footer.png    200  image/png  405 bytes

    A crawler reading status codes passes /img/banner.png. The content type is the tell: 200 with text/html under an image URL is an error page wearing a .png extension.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | complete=true, naturalWidth=0 | The image finished and decoded to nothing | Broken. Fix the src or restore the file. | | complete=false | The request has not finished or has not started | Not a result yet. Scroll, wait, and read again. | | naturalWidth above zero | Decoded, at that intrinsic width | Healthy, whatever the rendered size is. | | 200 with content-type: text/html | The server answered an error page under an image URL | Status-only checks pass this. Fix the origin or the CDN rule. | | A console 404 with no URL | Chrome does not name the resource in the message text | Take the URL from the network list, as in step 3. |

Common mistakes

Sign: A check on img.complete passes while the page shows empty boxes.Cause: complete means the browser has stopped working on the image, not that it succeeded. In the capture above it is true for both broken images. Pair it with naturalWidth, which is 0 when nothing decoded.
Sign: The console is clean and an image is still missing.Cause: A resource that answers 200 produces no console error, whatever the bytes are. /img/banner.png returned an HTML error page with a 200 status and logged nothing. The console is also missing the URL of the failures it does report, and one of the two errors in the capture was the favicon Chrome asks for on its own.
Sign: A lazy image below the fold is reported as broken.Cause: It has not been requested yet: complete is false and naturalWidth is 0, the same reading a broken image gives before it fails. Require complete to be true before calling anything broken, or scroll the element into view first.
Sign: A loop over img.decode() hangs with no output.Cause: decode() on an image the browser has not started fetching never settles. In the capture it was still pending after 3000 ms on the lazy image while it rejected the two broken ones immediately. Race it against a timeout, or skip images whose complete is false.

What to check next

FAQ

Why do images not load on a website?

Four causes account for most of it: the file was renamed or deleted, the path is right but the host is wrong, the server answers an error page with a 200 status, or the bytes are not a valid image. The first three are visible in the network list, the fourth only in naturalWidth.

How do I check a website for 404 errors?

For missing subresources, read the network list of a real load, as in step 3, since document.images alone cannot tell 404 from a decode failure. For missing pages, request each link and compare bodies.

Does a broken image always show in the browser console?

No. Only a failed request logs a message. An image URL that answers 200 with the wrong bytes is broken on screen and silent in the console, which is the banner row in step 3.

Can I find broken images with curl alone?

Partly. curl catches the 404s and the wrong content types, as in step 6. It cannot catch a file that is served as image/png but is corrupt, because only a decoder knows that.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2curl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic7 minpublished updated Maks Verny