How to Build a Website Screenshot Change Detector with Puppeteer
Build a Puppeteer visual change detector that captures a stable baseline, compares later screenshots, and produces a diff for review.
A website screenshot change detector captures a page under repeatable conditions, compares the new image with a saved baseline, and reports visual differences for review. Puppeteer handles browser automation and screenshots; it does not include a visual comparison assertion. This guide uses Puppeteer to capture PNGs and the separate pixelmatch and pngjs packages to create a diff image and pass/fail result.
The detector is only useful when the baseline and current capture use the same URL, viewport, capture area, browser environment, and readiness conditions. Treat a mismatch as a review signal, not proof that a user-facing regression occurred.
1. Create the project
Use a supported Node.js version and install the packages. Puppeteer downloads a compatible Chrome for Testing by default; if your environment supplies its own browser, configure that explicitly and keep its version consistent between runs.
mkdir screenshot-change-detector
cd screenshot-change-detector
npm init -y
npm install puppeteer pngjs pixelmatch
Create detector.mjs with the script below. It accepts a URL and an optional mode: baseline saves or deliberately replaces the approved reference, while compare captures the current page and compares it to that reference. The first run must create the baseline.
2. Capture, compare, and write a diff
import fs from 'node:fs/promises';
import puppeteer from 'puppeteer';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
const [url, mode = 'compare'] = process.argv.slice(2);
if (!url || !['baseline', 'compare'].includes(mode)) {
console.error('Usage: node detector.mjs <url> [baseline|compare]');
process.exit(2);
}
const referencePath = 'reference.png';
const currentPath = 'current.png';
const diffPath = 'diff.png';
const viewport = { width: 1440, height: 1000, deviceScaleFactor: 1 };
// Set this for your application. It should identify a meaningful ready state.
const readySelector = process.env.READY_SELECTOR;
// Project-specific tolerance: maximum changed pixels allowed.
const maxDiffPixels = Number(process.env.MAX_DIFF_PIXELS ?? 0);
if (!Number.isInteger(maxDiffPixels) || maxDiffPixels < 0) {
throw new Error('MAX_DIFF_PIXELS must be a non-negative integer');
}
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport(viewport);
// Reduce common sources of irrelevant variation. This does not make every
// page deterministic; control app data and other dynamic content as needed.
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
await page.addStyleTag({ content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
caret-color: transparent !important;
}
` }).catch(() => {}); // Navigation has not happened yet; use page-wide injection below.
await page.evaluateOnNewDocument(() => {
const style = document.createElement('style');
style.textContent = `*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; caret-color: transparent !important; }`;
document.addEventListener('DOMContentLoaded', () => document.head?.append(style));
});
const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
if (!response) throw new Error('Navigation returned no response (for example, a non-HTTP URL).');
if (!response.ok()) throw new Error(`Navigation failed: HTTP ${response.status()} ${response.statusText()}`);
if (readySelector) {
await page.waitForSelector(readySelector, { visible: true, timeout: 30000 });
}
// Let layout and paint settle after the chosen readiness condition.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const screenshot = await page.screenshot({
type: 'png',
path: currentPath,
fullPage: process.env.FULL_PAGE === '1',
animations: 'disabled'
});
if (mode === 'baseline') {
await fs.copyFile(currentPath, referencePath);
console.log(`Saved approved baseline to ${referencePath}`);
process.exitCode = 0;
} else {
let referenceBytes;
try {
referenceBytes = await fs.readFile(referencePath);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`No ${referencePath} found. Create and review one with: node detector.mjs ${url} baseline`);
}
throw error;
}
const reference = PNG.sync.read(referenceBytes);
const current = PNG.sync.read(screenshot);
if (reference.width !== current.width || reference.height !== current.height) {
console.error(`Image dimensions differ: baseline ${reference.width}x${reference.height}, current ${current.width}x${current.height}. Check viewport and FULL_PAGE settings.`);
process.exitCode = 1;
} else {
const diff = new PNG({ width: reference.width, height: reference.height });
const changedPixels = pixelmatch(reference.data, current.data, diff.data, reference.width, reference.height, {
threshold: Number(process.env.PIXEL_THRESHOLD ?? 0.1),
includeAA: false
});
await fs.writeFile(diffPath, PNG.sync.write(diff));
console.log(`Changed pixels: ${changedPixels}; allowed: ${maxDiffPixels}; diff: ${diffPath}`);
process.exitCode = changedPixels > maxDiffPixels ? 1 : 0;
}
}
} finally {
await browser.close();
}
The script disables CSS animations and transitions and asks the browser to disable finite animations during screenshot capture. The CSS is injected after document readiness in this example, so pages whose first paint depends on animation can still vary before then. For stricter control, inject a stylesheet before navigation using a controlled test build or a Puppeteer page-wide mechanism appropriate to your installed version. Do not hide a changing region unless that region is intentionally outside the test.
Run it
# Create the reference, then inspect reference.png before accepting it
node detector.mjs https://example.com baseline
# Compare a later capture; exit code 1 indicates a difference over tolerance
node detector.mjs https://example.com compare
# Wait for an app-specific ready element; capture full page; allow up to 25 pixels
READY_SELECTOR='main[data-ready="true"]' FULL_PAGE=1 MAX_DIFF_PIXELS=25 \
node detector.mjs https://example.com compare
The baseline command intentionally replaces reference.png. Keep reviewed baselines in version control or another reviewable artifact store, and update them only after checking the page and diff. In CI, preserve current.png and diff.png as artifacts when a comparison fails.
3. Choose what counts as a change
| Decision | Options | Guidance |
|---|---|---|
| Capture area | Viewport, full page, or a clipped region | Use the same dimensions and scope for baseline and current captures. Full-page screenshots can be taller and more sensitive to lazy content or page length changes. |
| Readiness | Network idle, visible selector, or application-specific ready state | networkidle2 is an example, not a guarantee. A site with polling may never become idle; a hydrated app may look idle before it is ready. Prefer a selector or app state that represents usable content. |
| Noise | Stabilize data, disable motion, or mask an irrelevant region | Prefer fixed test data, fixed clocks, and disabled rotation. If excluding a region, document why; masking can conceal a real defect. |
| Rendering environment | Browser, OS, fonts, scale, headless settings | Keep these stable. Browser rendering can vary with host OS, browser version, settings, hardware, power source, and headless mode, as the Playwright visual comparisons documentation explains. |
| Diff tolerance | Exact match or allowed pixel count / ratio | Start strict, inspect real diffs, then select a threshold appropriate to this page and environment. There is no universal correct threshold. |
| Baseline updates | Automatic replacement or reviewed update | Review the page and diff before replacing a reference so a regression is not silently accepted. |
pixelmatch‘s threshold controls per-pixel color sensitivity; MAX_DIFF_PIXELS controls the number of pixels allowed to differ in this example. These are separate controls. Calibrate both with representative changes. The example compares same-size PNGs and fails on a dimension mismatch rather than trying to align images automatically.
4. Puppeteer capture options and edge cases
Puppeteer’s screenshot guide says to use Page.screenshot(). Its screenshot options include output path, image format, full-page capture, clipping, and background handling. See the official Puppeteer screenshot guide, ScreenshotOptions API, and Page.screenshot API for the installed version’s exact options.
- Viewport: set it before navigating. Puppeteer specifically notes that many sites do not expect phones to change size, so the viewport should be set before navigation. See Page.setViewport. Mobile or touch emulation changes can trigger a reload.
- Full page:
fullPage: truecaptures beyond the viewport. Some lazy-loaded content only appears after scrolling; if it matters, scroll through the page and wait for images or app state before capture. - Clip: use the screenshot
clipoption for a fixed rectangle when only a region matters. Keep its coordinates and dimensions stable. - Element: locate the element and call its screenshot method for a focused component. Puppeteer scrolls an element into view when needed; a detached element causes an error. Re-query after navigation or rerender if the handle becomes stale.
- Transparent background:
omitBackground: truecan make transparent captures useful for assets, but compare it consistently and ensure the comparator handles alpha as expected. - Output: use PNG for pixel comparison because it is lossless. JPEG compression can create widespread small differences. Puppeteer can return image data or write to a path; this example uses PNG files.
- Dynamic content: freeze clocks and random data in a test environment where possible. Ads, personalized content, rotating banners, and third-party widgets can change between visits.
For a CSS override, explain what it removes and why. Playwright documents a stylePath filtering approach for its own test runner; that API is not a Puppeteer feature. In Puppeteer, inject CSS through its page APIs or your application’s test configuration.
5. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser fails to launch | Missing system libraries, unavailable downloaded browser, or a container restriction | Review Puppeteer’s launch error, install the required OS dependencies, or configure the executable path for a compatible installed Chrome. Pin the browser in CI. |
| Timeout during navigation | Slow server, long-running requests, or a wait mode that never completes | Use a suitable navigation wait mode, then wait for an app-specific ready selector. Set a longer timeout only when the page legitimately needs it. |
| Page looks incomplete despite network idle | Client hydration, delayed rendering, or lazy content | Wait for a meaningful visible element or application state, scroll if lazy loading is relevant, then allow layout and paint to settle. |
| Many diffs on every run | Different fonts/browser/OS, animations, timestamps, dynamic data, or scale | Pin environment and viewport; stabilize data and time; disable motion; verify the same font files load before capture. |
| Image dimension mismatch | Viewport, device scale factor, full-page setting, or document height changed | Compare the capture configuration and inspect whether page height itself is the intended regression. Do not crop away a meaningful layout change to force a pass. |
| Element screenshot reports detached node | The application replaced the element after it was selected | Wait for the component to settle and locate it again immediately before capture. |
| Baseline is missing | Compare mode ran before a reference was approved | Run baseline mode, inspect the generated image, and commit or store it as the approved reference. |
| CI exits with code 1 | Diff exceeds tolerance or dimensions differ | Inspect current and diff artifacts. Fix an unintended regression or deliberately review and update the baseline. |
6. Performance, reliability, and cost
A single capture is dominated by browser startup, page load, and rendering; comparison adds image decoding and per-pixel work proportional to image area. Reuse a browser process for multiple URLs in a controlled job, but use a fresh page and deterministic state per capture. Avoid excessive parallel pages if memory is constrained, especially for tall full-page images.
For reliability, pin Node, Puppeteer, browser, fonts, viewport, device scale, locale, timezone, and test data. Use explicit timeouts, always close the browser in a finally block, and save artifacts on failures. Keep retries limited to transient infrastructure failures: retrying an inherently unstable page can hide the instability instead of fixing it.
The self-hosted cost is the compute and storage for browser runs and image artifacts; there is no universal cost or runtime because pages and environments vary. Retain only the baselines and diffs your review process needs, and avoid running the same expensive capture more often than the change-detection cadence requires.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A GET request returns a screenshot or PDF, so it can replace the browser-capture part of this pipeline; you still choose and run your own image comparator and baseline review process. See the ScreenshotNeo API documentation for request options.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o current.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("current.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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(async fs => fs.writeFile('current.webp', Buffer.from(await res.arrayBuffer())));
- Cookie banners are accepted and removed before the shot, along with known newsletter popups and chat widgets; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Responses identify the page verdict and billing status in headers.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdftools for Claude, Cursor, and other MCP clients. - The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. The same features are on every plan.
Start with 1,000 free screenshots a month—no card required.
8. Frequently asked questions
Does Puppeteer include screenshot assertions?
No. Puppeteer captures screenshots; this tutorial adds a separate image comparison library. Playwright Test has its own visual comparison feature, but its assertion APIs are specific to that runner.
Should every pixel mismatch fail the build?
Only if the page and rendering environment are stable enough for exact matching. Otherwise, use a project-specific threshold and require review of the resulting diff.
When should I update the reference image?
After reviewing the current page and diff and deciding the visual change is intended. Treat reference updates as code changes that deserve review.


