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
- Chrome. This page used version 152.0.7977.76 on Windows. The browser figures are one capture on one machine on 2026-09-11.
- Node 22 or later with
puppeteer-core, installed withnpm i puppeteer-core. It drives the Chrome already on the machine. See the puppeteer API. The Chrome path in the scripts is the Windows default; on macOS it is/Applications/Google Chrome.app/Contents/MacOS/Google Chrome. - curl 8 or later for step 6.
- Save the test page as
images-server.mjs. It carries four cases: an image that works, one that answers 404, one that answers 200 with an HTML body, and one below the fold markedloading="lazy". Stop it when you are done.
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
- Step 1.
Start the page under test. Stop it with Ctrl-C at the end.
node images-server.mjsimage 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. - Step 2.
List the images that finished loading and produced nothing. Type the expression into the Console, or save the script as
broken-images.mjsand 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.
completealone says the browser stopped working on the image, whether it succeeded or failed, so thenaturalWidth === 0half carries the verdict. - 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.icoLine by line:
herodecoded,logoandbannerarecompletewith a natural width of zero,footerhas not started. The console shows two errors and neither names a URL. One of the two is/favicon.ico, which the markup never requested.bannerproduced no console message at all, because from the network's point of view nothing failed. - 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=240footernow has a natural width of 240. It was never broken, it had not been asked for. A filter that only testsnaturalWidth === 0reports it, which is why thecompletehalf of the pair matters. - Step 5.
Check what
decode()reports, since it looks like the tidier test. Save asdecode-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 msdecode()names both failures with anEncodingError, and it never settles for the lazy image. Written without the race above, that loop hangs onfooterand the script produces no output at all. - 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 bytesA crawler reading status codes passes
/img/banner.png. The content type is the tell: 200 withtext/htmlunder an image URL is an error page wearing a.pngextension.
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
What to check next
- How to check for broken links on a website: the same problem in
<a href>, where HEAD and GET disagree. - How to check if images are lazy loaded: how to tell a deferred image from a missing one before you file the bug.
- How to check console errors on a website: recovering the URL behind a message that does not carry one.
- How to check alt text on images: what the reader gets when an image does fail.
- How to check which srcset image the browser loaded: reading
currentSrcwhen an image has several candidates.
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.
Related on this site
basic7 minpublished updated Maks Verny