Puppeteer Screenshot Comparison: A Complete Visual Regression Guide
Capture deterministic Puppeteer screenshots, compare them with approved baselines, review diffs, and troubleshoot flaky visual regression tests.
Short answer: Puppeteer captures screenshots; it does not provide a built-in baseline comparison workflow. Use page.screenshot() or elementHandle.screenshot() to create the current image, store an approved reference, then compare the two files with an image-diff library or visual-testing service. Keep the URL state, browser, viewport, fonts, timing and capture options identical so a reported difference represents a likely UI change rather than rendering noise.
This guide builds a complete JavaScript workflow with Puppeteer and pixelmatch, including baseline creation, pixel tolerances, element and full-page captures, diff artifacts, CI usage, troubleshooting and an API alternative.
What Puppeteer screenshot comparison actually includes
Puppeteer documents screenshot capture, not approval storage or visual assertions. Its screenshot guide shows navigation followed by page.screenshot(), and the API also supports element screenshots. The Page.screenshot API can write an image file or return image data; capture settings include full-page, clipping, output type, path and transparent-background behavior. A separate comparison layer must decide how much difference is acceptable and what to do when a comparison fails.
| Layer | Responsibility |
|---|---|
| Puppeteer | Open the page, establish state and capture PNG, JPEG or WebP bytes. |
| Baseline store | Keep the approved reference image and associate it with a test or page state. |
| Diff library or service | Compare images, apply a tolerance and produce a changed-pixel count or diff image. |
| Review workflow | Show baseline, current and diff artifacts so a person can approve or reject the change. |
Choose the comparison scope
- Viewport: compares only what is visible in the browser viewport. This is useful for a component above the fold.
- Full page: captures the complete document. Use the same
fullPagesetting for both baseline and current images. - Clipped region: compares a known rectangle using
clip. Keep its coordinates and dimensions fixed. - Element: captures one selected element with
elementHandle.screenshot(). This usually reduces unrelated page noise.
Decide the scope before creating the baseline. A baseline made with a viewport capture cannot be compared reliably with a later full-page capture because the image dimensions and content differ.
Install Puppeteer and an image-diff library
mkdir puppeteer-visual-check
cd puppeteer-visual-check
npm init -y
npm install puppeteer pixelmatch pngjs
The example below uses PNG because it is lossless and straightforward to diff. JPEG compression can create changed pixels even when the page is unchanged.
Create a deterministic screenshot
import puppeteer from 'puppeteer';
import fs from 'node:fs/promises';
const url = process.env.TEST_URL || 'https://example.com';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: 'current.png', fullPage: true, type: 'png' });
} finally {
await browser.close();
}
networkidle2 is a useful starting point, but it is not proof that application data, fonts or animations are settled. Add an explicit selector wait or an application-ready signal when your page needs one.
Build and approve a baseline
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto('https://example.com', { waitUntil: 'networkidle2', timeout: 60000 });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: 'baseline.png', fullPage: true, type: 'png' });
} finally {
await browser.close();
}
Commit the baseline only after reviewing it. Store it with the test code or in the baseline system your team uses. Approval is a code-review decision: a changed image can be an intentional design update or a regression.
Compare baseline and current images with pixelmatch
import fs from 'node:fs';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
const baseline = PNG.sync.read(fs.readFileSync('baseline.png'));
const current = PNG.sync.read(fs.readFileSync('current.png'));
if (baseline.width !== current.width || baseline.height !== current.height) {
console.error(`Image dimensions differ: baseline ${baseline.width}x${baseline.height}, current ${current.width}x${current.height}`);
process.exit(1);
}
const diff = new PNG({ width: baseline.width, height: baseline.height });
const changedPixels = pixelmatch(
baseline.data,
current.data,
diff.data,
baseline.width,
baseline.height,
{ threshold: 0.1, includeAA: false }
);
fs.writeFileSync('diff.png', PNG.sync.write(diff));
const allowedChangedPixels = 100;
if (changedPixels > allowedChangedPixels) {
console.error(`Visual regression: ${changedPixels} changed pixels (allowed ${allowedChangedPixels})`);
process.exit(1);
}
console.log(`Visual comparison passed: ${changedPixels} changed pixels`);
The threshold and changed-pixel allowance are policy choices for this library and this page. Start strict, inspect real diffs, then allow only known harmless variation. A permissive tolerance can hide an actual layout or color change.
Capture only an element
const card = await page.waitForSelector('[data-testid="pricing-card"]', { timeout: 30000 });
await card.screenshot({ path: 'pricing-card.png', type: 'png' });
Element capture is appropriate when navigation, recommendations or unrelated sections change frequently. Use a stable selector and make sure the element is visible before capture.
Useful Puppeteer capture options
| Option | Use | Comparison concern |
|---|---|---|
fullPage |
Capture the complete page instead of the viewport. | Lazy content may not be present unless it is loaded first. |
clip |
Capture a rectangle with x, y, width and height. | Coordinates must remain stable. |
type |
Choose PNG, JPEG or WebP. | Use the same type for baseline and current images; PNG avoids compression artifacts. |
path |
Write the image to a file. | Use predictable artifact paths in CI. |
omitBackground |
Keep the page background transparent where supported. | Both captures need the same background behavior. |
Control the browser state before capture
- Fix the viewport and device scale factor. Set width, height and
deviceScaleFactorexplicitly. - Use one browser build and operating environment. Rendering can vary with host OS, browser version, settings, hardware, power source and headless mode. Playwright’s visual comparison guidance recommends generating and comparing images in the same environment.
- Wait for application readiness. Combine navigation waiting with a selector, a known data condition or a page-level ready marker.
- Wait for fonts.
await page.evaluate(() => document.fonts.ready)prevents fallback-font screenshots when web fonts load late. - Freeze animation and media. Inject CSS that disables transitions and animations, pause videos, and mock clocks or rotating content when those values are irrelevant to the test.
- Control data. Use seeded fixtures or a stable test account. A changing timestamp, ad, avatar or recommendation makes a noisy baseline.
- Keep locale and timezone stable. Date, number and translated text differences can alter layout.
await page.emulateTimezone('UTC');
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' });
await page.addStyleTag({ content: `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
` });
Use a complete comparison script
import fs from 'node:fs';
import puppeteer from 'puppeteer';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
const url = process.env.TEST_URL || 'https://example.com';
const baselinePath = 'baseline.png';
const currentPath = 'current.png';
const diffPath = 'diff.png';
const maxChangedPixels = Number(process.env.MAX_CHANGED_PIXELS || 100);
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.emulateTimezone('UTC');
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
await page.waitForSelector('body', { visible: true, timeout: 30000 });
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({ content: '* { animation: none !important; transition: none !important; }' });
await page.screenshot({ path: currentPath, fullPage: true, type: 'png' });
} finally {
await browser.close();
}
if (!fs.existsSync(baselinePath)) {
fs.copyFileSync(currentPath, baselinePath);
console.log(`Created ${baselinePath}. Review and commit it before enforcing comparisons.`);
process.exit(0);
}
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const current = PNG.sync.read(fs.readFileSync(currentPath));
if (baseline.width !== current.width || baseline.height !== current.height) {
throw new Error(`Dimensions differ: ${baseline.width}x${baseline.height} versus ${current.width}x${current.height}`);
}
const diff = new PNG({ width: baseline.width, height: baseline.height });
const changed = pixelmatch(baseline.data, current.data, diff.data, baseline.width, baseline.height, { threshold: 0.1 });
fs.writeFileSync(diffPath, PNG.sync.write(diff));
console.log(`Changed pixels: ${changed}; allowed: ${maxChangedPixels}`);
if (changed > maxChangedPixels) process.exit(1);
Run it in continuous integration
- Install the same Node.js and Puppeteer versions on every runner.
- Run the application with deterministic seed data.
- Capture the current screenshot and compare it with the checked-in baseline.
- Upload
baseline.png,current.pnganddiff.pngas CI artifacts when a comparison fails. - Review the diff in the change request. Update the baseline only when the visual change is intentional.
Do not make a failed comparison pass by automatically replacing the baseline. That removes the review step and can turn a regression into the new reference.
Performance, reliability and cost
- Performance: Browser startup is expensive. Reuse one browser process and create pages per test where isolation allows it. Capture only the viewport or element when a full-page image is unnecessary.
- Reliability: Keep browser, OS, fonts, viewport, timezone, locale and data stable. Retry navigation only when the failure is known to be transient; repeated retries can conceal a real problem.
- Artifacts: Save the three images for failed comparisons. A changed-pixel number alone cannot explain whether a shift is intentional.
- Cost: Self-hosted Puppeteer costs the compute time and storage used by your runner. A hosted visual-testing service can store baselines and reports, but pricing and retention depend on that provider.
- Scope: Compare a small stable component when the goal is component regression; use full-page checks for page-level layout and integration coverage.
Troubleshooting Puppeteer screenshot diffs
| Symptom | Likely cause | Fix |
|---|---|---|
| Every pixel differs | Different dimensions, viewport, browser or image type. | Log width, height, browser version and capture options; make them identical. |
| Text moves between runs | Fonts were not loaded or font files differ. | Wait for document.fonts.ready and install the same fonts on CI. |
| Only timestamps or ads differ | Live data or rotating content. | Seed fixtures, mock time, block or replace unstable content, or capture a stable element. |
| Bottom of a full-page image is blank | Lazy-loaded content was never triggered. | Scroll through the page before capture or use an application-ready condition. |
TimeoutError during navigation |
The page did not meet the selected wait condition in time. | Check connectivity and page health, increase the timeout only when justified, and wait for a narrower selector if network activity never becomes idle. |
| Element screenshot fails | Selector is wrong, element is hidden or it has not rendered. | Use waitForSelector, verify visibility and assert that the selector matches the intended element. |
| Small anti-aliasing halos | Rendering differences in text or edges. | Compare in the same environment, then tune the chosen library’s threshold narrowly. Do not use a broad tolerance without reviewing the diff. |
| Diff file is unreadable | Diff colors are not obvious against the page. | Keep baseline and current artifacts beside the diff, and configure the diff renderer or viewer used by your CI. |
| Comparison passes locally but fails in CI | Different OS, browser build, fonts, hardware or headless mode. | Pin the environment or run both baseline generation and comparison in the same image. |
Playwright assertions versus Puppeteer
Playwright Test documents a built-in screenshot assertion and options such as a threshold and maximum-difference allowance. That assertion belongs to Playwright Test; it should not be described as a Puppeteer feature. With Puppeteer, choose and configure your own comparison library or a hosted visual-testing workflow. See the Playwright visual comparisons documentation for the separate runner’s model.
Or skip the browser setup
ScreenshotNeo provides a website screenshot API when you need an image without maintaining Puppeteer in your service. It accepts one GET request and returns PNG, JPEG, WebP or PDF. Cookie and consent banners, newsletter popups and chat widgets are removed before capture; bot checks, blank pages, failed loads and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf.
See the ScreenshotNeo API documentation for all options. This cURL request captures a target page:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
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)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
const body = Buffer.from(await res.arrayBuffer());
ScreenshotNeo also supports full-page and element capture, dark mode, device presets or custom viewports, retina scale, custom CSS and JavaScript, waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage information and an OpenAPI specification. The parameter names used by other screenshot APIs also work, which can simplify migration.
The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan, and yearly billing gives two months free. Create a free ScreenshotNeo account.
FAQ
Does Puppeteer compare screenshots by itself?
No. Puppeteer captures the images. You supply the baseline store, diff library or hosted service, tolerance policy and review process.
Should I compare full pages or elements?
Use full pages for page-level layout coverage and elements for focused, stable component checks. Keep the chosen scope consistent between baseline and current captures.
Why are screenshots different on two machines?
Operating system, browser version, fonts, settings, hardware, power source and headless mode can affect rendering. Generate and compare images in the same controlled environment.
Is a changed pixel automatically a bug?
No. It is a signal for review. Approve a new baseline only after confirming that the visual change is intentional.
Can I use JPEG for visual regression?
You can, but compression introduces additional pixel changes. PNG is usually easier to interpret for strict comparisons.
