ScreenshotNeo

BlogHow-to

How to Compare Puppeteer Screenshots with Pixelmatch

Capture repeatable Puppeteer screenshots, compare their pixels with Pixelmatch, and turn the mismatch count and diff image into a useful visual regression check.

By the ScreenshotNeo team4 October 20268 min read

To compare Puppeteer screenshots with Pixelmatch, capture the baseline and current page as PNGs with the same viewport and capture options, decode both with pngjs, confirm their dimensions match, then pass their pixel buffers to pixelmatch. Use its mismatch count as your test result and save a diff PNG when you need to inspect what changed. Puppeteer documents Page.screenshot() for page captures; Pixelmatch requires equal image dimensions. (Puppeteer screenshots guide; Pixelmatch README)

1. Install the packages

This example uses Node.js with ES modules. Install Puppeteer, Pixelmatch, and pngjs in a project:

npm install puppeteer pixelmatch pngjs

Save the script below as compare.mjs. The first run creates baseline.png if it does not exist. Review that image and commit it to your test fixtures. Later runs compare the current render to that committed baseline. For a real test suite, generate the baseline deliberately and do not silently replace it when a comparison fails.

2. Capture and compare with a runnable Node.js script

import fs from 'node:fs';
import puppeteer from 'puppeteer';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';

const url = process.env.TARGET_URL ?? 'https://example.com';
const baselinePath = 'baseline.png';
const actualPath = 'actual.png';
const diffPath = 'diff.png';
const allowedDifferentPixels = Number(process.env.ALLOWED_DIFF_PIXELS ?? 0);

const browser = await puppeteer.launch({ headless: true });
try {
  const page = await browser.newPage({
    viewport: { width: 1365, height: 900 },
    deviceScaleFactor: 1,
  });

  await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
  // Replace this with an application-specific readiness condition when available.
  await page.waitForSelector('body');
  // Keep the test deterministic if the page has CSS animations or transitions.
  await page.addStyleTag({
    content: `*, *::before, *::after {
      animation: none !important;
      transition: none !important;
      caret-color: transparent !important;
    }`,
  });

  const actualBuffer = await page.screenshot({
    type: 'png',
    fullPage: false,
  });
  fs.writeFileSync(actualPath, actualBuffer);

  if (!fs.existsSync(baselinePath)) {
    fs.writeFileSync(baselinePath, actualBuffer);
    console.log(`Created ${baselinePath}. Review and commit it, then run again.`);
  } else {
    const before = PNG.sync.read(fs.readFileSync(baselinePath));
    const after = PNG.sync.read(actualBuffer);

    if (before.width !== after.width || before.height !== after.height) {
      throw new Error(
        `Screenshot dimensions differ: baseline ${before.width}x${before.height}, ` +
        `actual ${after.width}x${after.height}`,
      );
    }

    const { width, height } = before;
    const diff = new PNG({ width, height });
    const mismatchedPixels = pixelmatch(
      before.data,
      after.data,
      diff.data,
      width,
      height,
      { threshold: 0.1 },
    );
    fs.writeFileSync(diffPath, PNG.sync.write(diff));

    const percentage = (100 * mismatchedPixels / (width * height)).toFixed(3);
    console.log(`${mismatchedPixels} differing pixels (${percentage}%). Diff: ${diffPath}`);
    if (mismatchedPixels > allowedDifferentPixels) {
      process.exitCode = 1;
    }
  }
} finally {
  await browser.close();
}

The script compares the viewport by default. Set fullPage: true for the whole document, or use a clipping rectangle or an element screenshot when only a component matters. Keep that choice consistent between baseline and actual captures. Puppeteer’s screenshot options document full-page and clip capture. (Puppeteer screenshot options)

3. Understand the diff and set a pass policy

Pixelmatch returns the number of pixels it considers different. A nonzero count does not by itself explain whether a change is a defect: decide what your test should accept, and inspect the saved baseline, actual screenshot, and diff for failures. The example defaults to a strict zero-pixel allowance, which is appropriate only when the environment and page state are stable enough for it.

Option or choice Effect When to use it
threshold Color difference sensitivity from 0 to 1; documented default is 0.1. Lower values are more sensitive. Start with the default or a stricter value for stable rendering. Adjust only after identifying harmless variation.
includeAA Defaults to false. When true, Pixelmatch stops detecting and ignoring anti-aliased pixels, so more edge pixels can count. Enable when edge changes should count and anti-alias noise is not obscuring results.
diffColor, diffColorAlt Choose colors for changed areas in the visual output. Make additions and removals easier to distinguish during review.
aaColor, alpha, diffMask Control anti-alias marking, unchanged-pixel blending, or whether the output is a transparent diff mask. Customize the review artifact; these do not create a sound pass/fail policy on their own.
windowSize Instead of total differing pixels, returns the maximum count in any sliding N-by-N pixel window. Use when you want a compact changed region to weigh more than scattered rendering noise. Document that this is a local maximum, not a total.
Capture scope Viewport, full page, clipping region, or selected element. Choose the smallest scope that captures the behavior under test and keep it identical across runs.

Pixelmatch documents the threshold range and default, anti-alias behavior, diff rendering options, and windowed count in its API README. No single threshold or allowed-pixel count is correct for every application.

4. Capture states that can be compared reliably

  1. Fix the viewport and scale. Use the same viewport width, height, device scale factor, screenshot type, and full-page or clip settings. A changed viewport changes layout and image dimensions.
  2. Wait for the page’s meaningful ready state. networkidle2 is a useful navigation condition, but it does not prove that client-side data, fonts, images, or animations have settled. Wait for an application selector, a test hook, or the specific content your assertion needs.
  3. Stabilize changing content. Use seeded test data, fixed dates, deterministic API responses, and known accounts. Disable animations and hide blinking carets where appropriate. These are test-design practices, not Puppeteer guarantees.
  4. Keep the rendering environment consistent. Run with the same browser/runtime, operating system image, and available fonts where possible. Font substitution and renderer differences can move text and change many pixels.
  5. Choose the right capture boundary. Full-page shots can include content below the fold and lazy-loaded assets; element captures reduce irrelevant page changes. For an element, wait for it and use ElementHandle.screenshot().
  6. Keep failure artifacts. Retain baseline, actual, and diff files in CI artifacts when a test fails. The diff helps locate a regression, while the two source images show its context.

5. Command-line and language alternatives

Pixelmatch command line

After installing the dependencies, compare two PNG files directly. The optional final arguments set threshold and anti-alias handling:

npx pixelmatch baseline.png actual.png diff.png 0.1 false

The command reports a differing-pixel count and exits nonzero when pixels differ. Use the Node.js API when you need a custom allowance, application-specific capture, or CI reporting. (Pixelmatch CLI source)

cURL

Pixelmatch is an in-process Node.js library, so there is no Pixelmatch HTTP endpoint to call with cURL. cURL can fetch a page or an already-generated PNG for a separate workflow, but it cannot perform this comparison by itself. For example, fetch an accessible image fixture:

curl -L 'https://example.com/fixture.png' -o actual.png

Python

There is no Python Pixelmatch API in the cited workflow. If the test must be Python, capture with a browser automation library and compare decoded arrays using a Python image library; that is a different implementation and can have different comparison semantics. To use the documented Pixelmatch behavior, invoke the Node script from a Python test runner:

import subprocess

result = subprocess.run(
    ['node', 'compare.mjs'],
    check=False,
    text=True,
    capture_output=True,
)
print(result.stdout)
if result.returncode:
    raise AssertionError(result.stderr or 'Visual comparison failed')

6. Troubleshooting

Symptom Likely cause Fix
“Image dimensions do not match” or the script’s dimensions error Viewport, device scale, full-page mode, clip, or page layout differs. Log dimensions and compare capture options; restore identical capture settings before tuning Pixelmatch.
Nearly every pixel differs The page is in a different state, the wrong baseline was selected, or the capture dimensions/content changed substantially. Open baseline and actual images side by side. Confirm URL, data, viewport, fonts, authentication, and readiness condition.
Small diffs appear on each run Animations, timestamps, rotating content, network data, caret blinking, or renderer/font variation. Stabilize inputs, disable motion, wait for meaningful readiness, and align the browser environment. Use a justified allowance only after review.
Text edges are marked as changed Anti-aliasing differences or a sensitive threshold. Check fonts and runtime first. Understand the effect of includeAA and threshold before changing them.
PNG decode fails The file may be incomplete, not actually PNG, or an error page saved as an image. Check the file type and capture result; preserve the raw bytes and regenerate the fixture.
The baseline gets recreated and test passes unexpectedly Baseline setup is mixed into normal test execution. Separate an explicit baseline update command from comparison. In CI, fail if the expected baseline is missing.
Navigation times out despite a page appearing Long-lived requests or page behavior prevent the chosen wait condition from completing. Choose a suitable navigation wait condition, then explicitly wait for the page element/state required by the test. Do not treat a timeout as proof the screenshot is valid.

7. Performance, reliability, and cost

The comparison works on decoded image buffers, so time and memory grow with screenshot pixel count; full-page images use more memory than a small component capture. Keep captures scoped to the behavior under test, and avoid launching more browser work concurrently than the CI worker can sustain. Save diff images only when review value justifies the storage.

For repeatability, pin the browser/runtime through your project’s normal dependency and CI setup, control test data, and preserve failure artifacts. Pixelmatch produces a deterministic count for the same input bytes and options; that does not make page rendering deterministic across different environments. Pixelmatch and pngjs run locally, so they add no per-comparison API charge; compute and CI runtime still have infrastructure cost.

8. Or skip the browser setup

If your goal is to obtain a page screenshot rather than maintain a browser-based visual test harness, ScreenshotNeo is a website screenshot API and MCP server. Its one-call capture can return PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for parameters and response details.

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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs').then(({ default: fs }) => fs.promises.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

ScreenshotNeo removes cookie banners, popups, and chat widgets before capture; bot checks, blank pages, and failed loads are not billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots each month with no card, and paid plans start at $5 for 3,000. This API capture does not replace the baseline-versus-actual Pixelmatch comparison shown above; it can remove the browser setup when you need a clean screenshot.

Sign up free for 1,000 screenshots a month, with no card required.

9. FAQ

Can Pixelmatch compare JPEG screenshots?

Pixelmatch operates on decoded pixel arrays. The workflow here uses PNG because Puppeteer produces PNG by default and pngjs decodes it. Lossy JPEG compression can introduce pixel differences unrelated to a page change, so PNG is the straightforward choice for regression fixtures.

Should I fail on one changed pixel?

Only if your capture environment and page state are stable enough to make that rule meaningful. Pick and document the allowance from reviewed examples, and preserve diff artifacts so failures remain diagnosable.

Does a passing comparison prove the page is correct?

No. It means the selected capture is within the chosen pixel policy relative to its baseline. Pair visual checks with functional assertions for behavior, content, and accessibility.

Can I compare only one element?

Yes. Puppeteer supports screenshots from an element handle. Use the same selector, element state, and dimensions for both baseline and actual captures, then compare the resulting PNG data as usual.

References