How to Test a Website for Broken Images
Find broken images with DevTools, DOM checks, HTTP verification, and Playwright—including lazy-loaded, private, and responsive images.
Short answer: test broken images in two layers. Inspect image requests in DevTools to see status codes, redirects, headers, and response bodies, then check the rendered DOM for images whose request finished but whose naturalWidth is zero. HTTP reachability alone misses corrupted files, unsupported formats, lazy-loaded images, and browser-only failures.
For repeatable checks, run the same assertions in an automated browser, include authenticated and localized states, and report each image’s final URL, status, content type, and decoding result. Keep missing alternative text, responsive layout defects, and oversized downloads as separate checks.
1. What counts as a broken image?
An image is broken when the browser cannot obtain usable image data for an <img> element. Causes include a 404 or 410, server errors, bad redirects, permission or CDN rules, mixed content, HTML or JSON returned instead of image bytes, corrupted files, and unsupported formats. The HTML specification says fatal corruption or an unsupported format puts the element in the broken state and fires an error event.
A 200 response is useful evidence, but it is not proof that the browser decoded the body. Pair network evidence with the rendered result and the DOM test below.
2. Manual test in Chrome DevTools
- Open the page in a current browser. If it requires login, sign in and record that the result applies to that account and role.
- Perform a clean reload. Open DevTools, select Network, enable Disable cache, and reload.
- Filter requests to Img. Chrome’s Network panel exposes image status, headers, timing, and response data.
- For suspicious requests, record the final URL, status, redirect chain,
Content-Type, cache state, initiator, and response body. Look for 4xx/5xx responses, blocked or mixed-content requests, unexpected redirects, and HTML or JSON bodies. - After lazy content appears, run this in the Console:
const broken = [...document.images].filter(img =>
img.complete && img.naturalWidth === 0
);
console.table(broken.map(img => ({
src: img.currentSrc || img.src,
alt: img.alt,
loading: img.loading
})));
naturalWidth is the intrinsic density-corrected width in CSS pixels; it is zero when intrinsic image data is unavailable (MDN). The complete guard avoids flagging an image before its request finishes. Scroll through the page or trigger interactions that load lazy images, then run the check again.
Attach an error listener before exercising lazy loading:
const failures = [];
for (const img of document.images) {
img.addEventListener('error', () => failures.push({
src: img.currentSrc || img.src,
alt: img.alt
}), { once: true });
}
// Scroll and interact, then inspect:
console.table(failures);
3. Check the rendered DOM, not just URLs
Use both currentSrc and src. Responsive images can select a different srcset candidate, and <picture> can select a different source by viewport or media query. A crawler that reads only initial HTML can miss the URL the browser actually used.
Check images inserted after load, revealed by menus or tabs, and loaded after scrolling:
console.table([...document.images].map(img => ({
src: img.currentSrc || img.src,
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
alt: img.getAttribute('alt')
})));
Missing alt is an accessibility issue, not proof that a file is broken. W3C explains that alternative text must convey an image’s purpose and that automated checks cannot judge whether wording is appropriate in context (W3C guidance). An image can load and still overflow at 400% zoom; test responsive sizing and reflow using W3C technique C37.
4. Automate a browser check with Playwright
This script records image responses, waits for the page to settle, scrolls to trigger lazy loading, and reports HTTP failures plus images the browser could not decode.
import { chromium } from 'playwright';
const target = process.argv[2] || 'https://example.com';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const imageResponses = [];
const requestErrors = [];
page.on('response', response => {
const request = response.request();
if (request.resourceType() === 'image') imageResponses.push({
url: response.url(), status: response.status(),
contentType: response.headers()['content-type'] || '',
fromServiceWorker: response.fromServiceWorker()
});
});
page.on('requestfailed', request => {
if (request.resourceType() === 'image') requestErrors.push({
url: request.url(), error: request.failure()?.errorText
});
});
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForLoadState('networkidle').catch(() => {});
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 700) {
window.scrollTo(0, y);
await new Promise(resolve => setTimeout(resolve, 100));
}
window.scrollTo(0, 0);
});
await page.waitForTimeout(1000);
const domBroken = await page.locator('img').evaluateAll(images => images
.filter(img => img.complete && img.naturalWidth === 0)
.map(img => ({ src: img.currentSrc || img.src, alt: img.alt })));
console.log(JSON.stringify({
page: target,
httpFailures: imageResponses.filter(r => r.status >= 400 || !/^image\\//i.test(r.contentType)),
requestErrors, domBroken
}, null, 2));
await browser.close();
Install with npm install playwright, then run node check-images.mjs https://your-site.example. Add storage state for authenticated pages, set locale and timezone for localized pages, and test important viewports. Treat a timeout as an incomplete result requiring investigation, not proof that every image is broken.
5. Verify image URLs with HTTP
HTTP checks are fast for crawls but cannot prove browser decoding. Send HEAD when supported, then fall back to GET. Follow redirects and record the final URL and content type.
curl -I -L --max-time 30 https://cdn.example.com/assets/hero.webp
# If HEAD is rejected:
curl -sS -L -D - -o /dev/null --max-time 30 https://cdn.example.com/assets/hero.webp
Responses normally have an image media type such as image/png, image/jpeg, image/webp, or image/avif. Some CDNs omit or misstate the header, so use browser decoding or an image parser before failing a deployment solely on Content-Type. Check private URLs with the same cookies or authorization headers as the page.
6. Cover a whole site with a crawl plan
- Discover: crawl sitemaps, navigation, feeds, representative templates,
src,srcset,<source>, and JavaScript-generated URLs. - Render: use a real browser for lazy loading, client-side rendering, authentication, geolocation, or consent gates.
- Exercise: scroll, open tabs and menus, and trigger states that reveal images.
- Record: save page URL, image URL, final URL, status, content type, redirects, errors, viewport, locale, auth scope, and decode result.
- Reduce false positives: retry transient 5xx and network failures, compare regions or cache states when relevant, and inspect non-image response bodies.
- Gate releases: fail on confirmed broken images; report missing
alt, layout overflow, and oversized assets separately.
The W3C Link Checker can complement an image-focused crawl. Lighthouse audits one page’s performance, accessibility, best practices, and SEO; it does not guarantee that every URL and template was tested.
7. Troubleshooting matrix
| Symptom | Evidence | Fix |
|---|---|---|
| 404 or 410 | Network status identifies a missing resource. | Correct URL, file name, case, deployment path, or stale CMS reference. |
| 500 or 503 | Origin or CDN returns a server error. | Check deployment logs, origin health, CDN rules, and retry behavior. |
| 200 but broken icon | Body is HTML/JSON, bytes are corrupted, or naturalWidth is zero. |
Verify bytes, transforms, content type, permissions, and format support. |
| Works for some users only | Cookies, auth, geolocation, cache, or user agent differ. | Reproduce the relevant session and region; compare final URLs and headers. |
| Only lazy images fail | Failure appears after scroll or interaction. | Exercise those states and inspect lazy URL construction and CDN paths. |
| Loads but overflows at zoom | Visual failure despite successful decoding. | Use responsive sizing such as max-width:100%; test C37 reflow. |
| Loads but is very heavy | Lighthouse reports a rendered-size or encoding opportunity. | Serve an appropriately sized responsive asset; treat as delivery performance. |
8. Performance, reliability, and cost notes
Use cheap HTTP checks for obvious missing URLs, then reserve browser runs for dynamic, authenticated, or high-value templates. Reuse browser contexts, limit concurrency to what the origin can handle, and cache immutable results by URL plus relevant headers, cookies, viewport, and locale. Keep retries bounded so they do not hide outages or overload the origin.
Run checks from regions and identities that matter to users. A single successful request cannot establish site-wide confidence when content varies by login, geography, consent, cache, or device. Store evidence so later runs can distinguish a regression from a transient timeout.
9. Or skip the browser setup
ScreenshotNeo captures the rendered page through one request. See the ScreenshotNeo API docs for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, and cache hits are not billed, and X-Page-Verdict and X-Billed report the result. The MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
10. FAQ
Is a 404 the only broken-image signal?
No. A 200 response can contain HTML, corrupted bytes, or an unsupported format. Confirm decoding with naturalWidth and an error listener.
Should alt text be part of the broken-image check?
Run it as a separate accessibility check. Missing or unsuitable alternative text does not prove that the image failed to load.
How do I test images behind login?
Run the browser with the correct storage state or login flow, and label results by account and role. Anonymous HTTP checks cannot validate private URLs.
How often should this run?
Run a focused check on every deploy and a broader crawl on a schedule matching how often content and image assets change.


