How to Detect Broken Images with Website Screenshot Monitoring
Combine browser checks of image loading with screenshot comparisons to catch broken images, confirm visible changes, and keep useful evidence.
To detect broken images reliably, check each relevant image element in the browser and pair that check with a screenshot of the rendered page. An image check tells you whether an image finished loading and has intrinsic dimensions; a screenshot comparison shows whether the page looks different from an approved reference. A screenshot difference alone cannot prove that an image failed: text changes, layout shifts, fonts, and rendering variation can also change pixels.
This guide builds a Playwright monitor that checks image state, reports failed image URLs, triggers lazy-loaded content, saves a screenshot as evidence, and compares the page with a reviewed baseline. It also explains the limits of link checks and hosted visual monitoring, how to control noisy screenshots, and what to do when the monitor fails.
1. Choose the right signals
Use two complementary signals:
- Browser image state: For an image that should have loaded, check
img.completeandimg.naturalWidth > 0. The MDN documentation forcompletedescribes whether an image has completed loading; MDN documentsnaturalWidthas the image’s intrinsic width, or zero when intrinsic data is unavailable. - Screenshot comparison: Compare a stabilized capture with an approved baseline. It can reveal an empty image area, fallback icon, unexpected crop, or shifted layout, but does not identify the cause by itself. See Playwright’s visual comparisons guide.
The image-state check is more specific to loading; the screenshot provides human-readable visual context. Preserve both when a check fails. A raw HTTP status check for an image URL is useful supplementary evidence, but it does not establish that the browser displayed the intended image. A successful response may still contain unusable image data, while browser behavior can be affected by redirects, authentication, content policies, lazy loading, and the page’s own source selection.
2. Set up a Playwright monitor
The following runnable example uses Playwright Test and JavaScript. It checks visible, rendered image elements after scrolling through the page, waits briefly for image loading, and captures a screenshot. If you have not created a visual baseline, Playwright creates one on the first run; inspect it before treating it as the expected page. A generated baseline is a reference file, not proof that the page is correct.
- Use a supported Node.js installation in your CI runner or development environment.
- Initialize an npm project and install Playwright Test and its browser:
npm init -y
npm install --save-dev @playwright/test
npx playwright install chromium
Create playwright.config.js:
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 60_000,
expect: {
timeout: 10_000,
toHaveScreenshot: {
animations: 'disabled',
caret: 'hide',
// Keep comparison strict at first. Tune only after reviewing real noise.
maxDiffPixels: 0,
},
},
use: {
browserName: 'chromium',
viewport: { width: 1365, height: 900 },
deviceScaleFactor: 1,
screenshot: 'only-on-failure',
},
});
Create tests/images.spec.js. Replace the example URL with a route you own or are authorized to monitor.
const { test, expect } = require('@playwright/test');
const pageUrl = 'https://example.com/';
async function scrollThroughPage(page) {
await page.evaluate(async () => {
const step = Math.max(window.innerHeight * 0.8, 400);
for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
window.scrollTo(0, y);
// Give intersection observers and lazy loaders a chance to run.
await new Promise(resolve => setTimeout(resolve, 150));
}
window.scrollTo(0, 0);
});
}
async function waitForImagesToSettle(page, timeoutMs = 15_000) {
await page.waitForFunction((timeout) => {
const images = [...document.images].filter(img => {
// Ignore images which are not rendered or have no source candidates.
const style = getComputedStyle(img);
const rendered = style.display !== 'none' && style.visibility !== 'hidden';
return rendered && (img.currentSrc || img.getAttribute('src') || img.src);
});
return images.every(img => img.complete) || Date.now() > window.__imageWaitDeadline;
}, timeoutMs, { timeout: timeoutMs + 1_000 }).catch(() => {});
}
test('important page images load and the page matches its visual baseline', async ({ page }) => {
// A timeout prevents a slow or long-polling resource from blocking navigation forever.
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
// Allow the page's own scripts and layout to run before checking its state.
await page.locator('body').waitFor({ state: 'visible' });
await scrollThroughPage(page);
await page.evaluate(() => {
window.__imageWaitDeadline = Date.now() + 15_000;
});
await waitForImagesToSettle(page);
const imageResults = await page.locator('img').evaluateAll(images =>
images.map((img, index) => {
const style = getComputedStyle(img);
const box = img.getBoundingClientRect();
const rendered = style.display !== 'none' &&
style.visibility !== 'hidden' && box.width > 0 && box.height > 0;
const source = img.currentSrc || img.getAttribute('src') || '';
return {
index,
source,
alt: img.getAttribute('alt'),
complete: img.complete,
naturalWidth: img.naturalWidth,
rendered,
};
})
);
// Report rendered images that have a source but did not produce intrinsic pixels.
const broken = imageResults.filter(image =>
image.rendered && image.source && (!image.complete || image.naturalWidth === 0)
);
if (broken.length) {
console.error(`Broken or unfinished images on ${pageUrl}:`);
console.error(JSON.stringify(broken, null, 2));
}
expect(broken, `Images failed to load on ${pageUrl}`).toEqual([]);
// Keep a visual signal alongside the diagnostic image-state assertion.
await expect(page).toHaveScreenshot('important-page.png', { fullPage: true });
});
There is a small deadline detail in this example: set the deadline before calling the settling helper. To make that ordering explicit and avoid any dependence on a page-global deadline, replace the two calls above with this simpler, self-contained wait if preferred:
await page.waitForFunction(() =>
[...document.images].every(img => img.complete),
{ timeout: 15_000 }
).catch(() => {
// Continue so the subsequent assertion reports the image URLs and state.
});
Run the check with:
npx playwright test
The first execution may write screenshot snapshots. Review the actual screenshot and confirm that the page state is correct before committing the snapshot. Later runs compare against that reference. Update snapshots only after reviewing an intentional page change:
npx playwright test --update-snapshots
Make the image wait simpler and precise
The example’s timeout should be implemented before the image-state collection. For production, use this direct version, which avoids page-global coordination. It waits for all current image elements to complete, but returns control at the timeout so the assertion can report incomplete images:
await page.waitForFunction(() => {
const images = [...document.images];
return images.every(img => img.complete);
}, { timeout: 15_000 }).catch(() => {});
For pages that continuously add images, scope the check to a known content region instead of waiting on every image indefinitely. For stricter control, collect the expected images after triggering the lazy loaders and explicitly assert that each expected source appears and loads. A page with no image elements will pass an empty-list check; if images are required, add a separate assertion for a minimum count or known selectors.
3. Cover lazy loading, responsive sources, and page states
A monitor that only checks the first viewport can miss failures below the fold. Lazy-loaded images may not start until they approach the viewport. Scroll through the page, or use application-specific interactions such as opening tabs, expanding accordions, or advancing a carousel before checking. Include the states that matter to customers: a page may have different images after login, locale selection, consent handling, or a user action.
- Responsive images: Inspect
currentSrc, which reflects the candidate selected by the browser fromsrcsetandsizes. Logging only thesrcattribute can hide which asset actually loaded. - CSS backgrounds:
document.imagesdoes not include CSS background images. If these are business-critical, check the relevant computedbackground-imageURLs and consider validating their network responses or capturing their rendered state. An image element check cannot certify a CSS background. - SVG and inline graphics: Inline SVG is not an
<img>. External SVG used through an image element is covered by the image-state check, but a missing inline SVG requires DOM or visual checks. - Hidden images: Decide whether hidden carousel slides and responsive variants should be checked. Hidden elements may load lazily only after interaction. The example filters visually hidden or zero-sized images to focus on rendered content; remove that filter if hidden assets are also required to be healthy.
- Decorative images: An empty
altvalue is valid for decorative imagery and does not mean the asset is broken. Treat missing image data as a loading issue, not an accessibility label issue. - Broken-image placeholders: A site may intentionally replace failed assets with a fallback. The image element can still fail its load-state check even when the fallback makes the page look acceptable. Decide whether the expected behavior is to fail monitoring or to assert the fallback separately.
4. Make screenshot comparisons stable
Playwright’s toHaveScreenshot() waits until two consecutive captures match, then compares the capture to its expected snapshot. This reduces noise from a page that is still settling, but it does not make a volatile page deterministic. The PageAssertions API reference documents screenshot options, including animation handling, masking styles, thresholds, and timeouts.
| Source of noise | Practical control |
|---|---|
| Different operating system, fonts, browser version, or hardware | Generate and compare baselines in the same CI image and browser project. Playwright notes that rendering can vary by environment. |
| Animations and blinking carets | Use animations: 'disabled' and caret: 'hide' in screenshot assertions. |
| Timestamps, rotating promotions, live counters | Use a screenshot stylesheet to hide or stabilize volatile regions. Mask only elements whose content is expected to vary. |
| Third-party content | Block or replace nonessential third-party content for repeatable visual checks, while retaining a separate check if that content is itself the subject of monitoring. |
| Expected small pixel variation | Adjust threshold, maxDiffPixels, or maxDiffPixelRatio deliberately after reviewing baseline diffs. Keep the threshold tight enough that a missing image still fails. |
For example, create tests/screenshot.css:
[data-visual-volatile], .live-clock, .rotating-promo {
visibility: hidden !important;
}
Then pass the stylesheet to the assertion (the screenshot assertion supports stylePath):
await expect(page).toHaveScreenshot('important-page.png', {
fullPage: true,
stylePath: './tests/screenshot.css',
animations: 'disabled',
caret: 'hide',
});
Masking or hiding can also conceal a real regression if applied too broadly. Keep the image element checks active in regions masked for visual stability, and review every proposed baseline update rather than accepting all changes automatically.
5. Preserve useful failure evidence
When a check fails, save enough context to reproduce and diagnose it. At minimum retain:
- Page URL, test name, timestamp, and relevant user state.
- Image source selected by the browser (
currentSrc), index or selector,complete, andnaturalWidth. - A screenshot of the page and, when practical, the Playwright diff or actual screenshot.
- Browser and runner versions, viewport, device scale factor, and test output.
- Whether the failure was an image-state assertion, a screenshot mismatch, a navigation timeout, or another error.
Use a stable test identity for each route and state. If the monitor covers many pages, put URLs and expected conditions in a small data list and run one test per route, so one failed route does not prevent the remaining pages from being checked. Avoid overly aggressive concurrency against the site: parallel visits can burden the origin or trigger rate limits and can make third-party assets less reliable.
6. Understand what hosted monitoring can and cannot do
A hosted screenshot service can capture a page on a schedule or on demand and give you an image to inspect. A screenshot by itself cannot report that a specific <img> element has naturalWidth === 0 unless the service also exposes browser-side inspection or an appropriate page script. Use the service screenshot as visual evidence, and keep a browser-level image check for a definitive element-state signal.
AWS CloudWatch Synthetics offers a visual monitoring blueprint for supported runtimes. AWS documents that the visual monitoring blueprint is not supported on Playwright runtimes; its broken-link checker identifies URL-level problems such as 404s or invalid hostnames, which is distinct from confirming that an image rendered. Check the current AWS canary blueprint documentation when selecting a runtime.
For teams that want shared screenshot baselines and review workflows, Visual Regression Tracker documents screenshot uploads from Playwright and other runners, baseline differences, approvals, and CI gating. It is an optional visual-regression layer; retain the direct image check for load-specific diagnosis.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. For an image monitor, use a scheduled caller to capture the route and retain the screenshot for visual review. This one-call API returns a screenshot; it does not replace the browser-side complete/naturalWidth assertion in the Playwright workflow above.
cURL:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com/ \
-o shot.webp
Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', bytes);
See the ScreenshotNeo API documentation for request parameters. Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.
Sign up for ScreenshotNeo and get 1,000 free screenshots a month, with no card.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The image check reports a failure, but the page looks fine | A fallback, placeholder, or CSS background hides the failed image element. | Inspect the reported currentSrc and decide whether fallback behavior satisfies the requirement. Check CSS backgrounds separately. |
| The monitor misses an image below the fold | The asset is lazy-loaded and the browser never scrolled near it. | Scroll through the document or interact with the section before collecting image state; verify that the page added the expected image element. |
naturalWidth is zero during a transient load |
The check ran before loading finished, or loading is stalled. | Wait for complete with a finite timeout, then report unfinished elements as failures with their source and page context. |
| A screenshot diff appears even though images work | Content changed, layout shifted, or the rendering environment differs. | Inspect the actual and baseline images, keep the CI environment consistent, and mask only known volatile regions. |
| A screenshot diff misses a broken image | The image region is small, similar to its background, or the comparison threshold is too permissive. | Use the image-element assertion as the primary loading signal and tighten screenshot thresholds after reviewing normal variation. |
| The test times out on navigation | The site keeps network connections open or a resource is slow. | Use a finite navigation timeout and wait for the page state you need, such as domcontentloaded, rather than requiring every network request to finish. |
| The first run fails because a snapshot is missing | No baseline exists yet. | Review the newly generated actual screenshot and create or commit the baseline only after confirming it represents the intended page. |
| Images pass locally and fail in CI | Environment, credentials, network access, locale, or viewport differs. | Match browser, OS image, viewport, device scale, authentication, and relevant page state between baseline generation and CI. |
Image source in the report differs from the HTML src |
The browser selected another responsive candidate. | Log currentSrc, which identifies the selected source, alongside the raw attributes. |
9. Performance, reliability, and cost
Full-page screenshot monitoring costs more time and storage than checking only a few critical image elements, especially on long pages. Scrolling increases coverage for lazy content but adds runtime and can trigger repeated network activity. Choose a small set of important routes and states, then expand coverage where an image failure would matter. Run at a cadence appropriate to the risk and the site’s change rate; no universal schedule fits every site.
Use finite timeouts for navigation and image settling so a stuck resource cannot hang a monitor indefinitely. Treat a timeout as its own failure category rather than silently marking all pending images as broken. Retry transient infrastructure errors cautiously; repeated retries can hide intermittent user-facing failures and can increase requests to the origin. Keep screenshots and logs long enough to investigate recurring failures, subject to your own storage and privacy requirements.
Visual snapshots are environment-dependent. Pin the browser environment used for both baseline and comparison, and update reference images through code review. Tight thresholds catch subtle changes but may produce more review work; permissive thresholds reduce noise while risking missed regressions. Tune thresholds against known normal variation without weakening the direct image-state assertion.
10. FAQ
Can a screenshot alone prove that an image is broken?
No. It can reveal a visible difference, but the change could have another cause. Inspect the image element’s load state to diagnose the failure.
Should I check every image on every route?
Start with images and routes that matter to users, including below-the-fold and interactive content. Expand coverage based on the consequences of a missed failure and the cost of running the monitor.
Does a 404 checker validate images?
It can find some unavailable URLs, but a URL check does not confirm that the browser rendered the expected image. Pair URL checks with browser image state when rendering matters.
Can I use Playwright screenshots without Playwright Test?
Playwright can capture screenshots outside the test runner, but its toHaveScreenshot() assertion is a Playwright Test feature. Use the documented runner when you want its baseline assertion workflow.


