A pixel diff compares two images position by position and reports the pixels whose colour differs by more than a threshold; a perceptual diff instead scores how different the images look to a person, usually by comparing local structure, brightness and contrast.
Both answer "did this screenshot change?". They disagree on what counts as a change, and the disagreement decides whether a visual test is useful or noisy. This entry is part of the screenshot glossary.
The difference in plain terms
A pixel diff is literal. It walks both images, compares the colour at (0,0), then (1,0), and so on, and counts the mismatches. It can also paint them red in a third image, which is the picture most people know from test reports.
A perceptual diff is statistical. A common one, SSIM (structural similarity), splits the image into small windows and compares mean brightness, contrast and the pattern inside each window. The result is a score from about 0 to 1, where 1 means the same structure.
The choice matters most in visual regression testing, where the diff is the pass or fail signal.
What each method reports: four controlled changes
We captured example.com at 1200×630, then captured it again four ways, and compared each with the first capture. Every capture pinned prefers-color-scheme: light, because on our test machine headless Chrome otherwise followed the dark system theme.
| Change | Pixels changed, threshold 0 | Pixels changed, threshold 0.1 | Share at 0.1 | SSIM |
|---|---|---|---|---|
| None (a second capture) | 0 | 0 | 0.00% | 1.0000 |
| Whole page moved 1 px right | 21,705 | 16,776 | 2.22% | 0.9362 |
Text colour #000000 to #010101 |
9,516 | 0 | 0.00% | 1.0000 |
| One word replaced in the paragraph | 5,971 | 5,149 | 0.68% | 0.9756 |
Method: Puppeteer 25.14.0 with headless Chrome 155 on macOS (Apple M1 Pro), 2026-10-15. pixelmatch 8 with default options apart from the threshold. SSIM is the mean over 8×8 luma windows, computed by the script that produced this table. Each row is one capture; the identical row confirms the page renders the same way twice.
Three things stand out:
- Threshold 0 is noise. A colour change of one step out of 255 flagged 9,516 pixels. At 0.1 it flagged none.
- A shift beats a content change. Moving everything by one pixel produced three times more changed pixels than a reworded sentence. SSIM agreed: it scored the shift as the bigger change.
- Neither method knows what matters. The word change is the real regression, and both methods ranked it last.
How it works technically
pixelmatch does not compare raw RGB. It converts each pixel to the YIQ colour space and measures a weighted distance, so the threshold option (0 to 1) roughly follows perceived colour difference. By default it also detects anti-aliased pixels, the soft edges of text and shapes, and does not count them. That is why the counts at threshold 0.1 are lower than at 0.
SSIM compares windows, not pixels, so a one-step colour change inside a window barely moves its score. A shift, though, changes the pattern in every window that holds an edge, so SSIM drops. Perceptual hashes (dHash, pHash) go further: they shrink the image to a few dozen pixels and compare a fingerprint. They are good for "is this the same page?" and too coarse for UI tests.
How to set it
Puppeteer with pixelmatch
Puppeteer has no diff of its own. Capture PNGs (lossless) and compare them:
import { readFile, writeFile } from 'node:fs/promises'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; const [a, b] = await Promise.all(['baseline.png', 'current.png'].map(async (f) => PNG.sync.read(await readFile(f)))); if (a.width !== b.width || a.height !== b.height) throw new Error('Image sizes differ; compare like with like'); const diff = new PNG({ width: a.width, height: a.height }); const changed = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold: 0.1 }); await writeFile('diff.png', PNG.sync.write(diff)); const ratio = changed / (a.width * a.height); console.log(`${changed} pixels changed (${(ratio * 100).toFixed(2)}%)`); if (ratio > 0.01) process.exitCode = 1; // fail the build above 1%
Playwright Test
Playwright Test has the comparison built in. threshold is the same per-pixel YIQ tolerance, and maxDiffPixelRatio is the share of pixels allowed to change:
import { test, expect } from '@playwright/test'; test('home page matches baseline', async ({ page }) => { await page.goto('https://example.com'); await expect(page).toHaveScreenshot('home.png', { threshold: 0.1, maxDiffPixelRatio: 0.01, animations: 'disabled', }); });
Tested on 2026-10-15 with Node 22.18.0, pixelmatch 8 and @playwright/test 1.64.1 (Chromium 156), with viewport 1200×630 and colorScheme: 'light' set in the config. The test passed against its own baseline and failed on the 1 px shift with "16670 pixels (ratio 0.03 of all image pixels) are different".
RenderScreenshot
The API does not diff images. It gives you the stable capture step: the same browser build, viewport and fonts for the baseline and for every later run. Request PNG (the default for the og_card preset) and send both images to the pixelmatch code above. Turn off the cache for the candidate capture, or you compare the baseline with itself:
curl -sS --fail -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com", "preset": "og_card", "browser": {"dark_mode": false, "reduced_motion": true}, "cache": {"enabled": false}}' \ -o current.png
dark_mode: false pins the light theme and reduced_motion: true asks the page to skip its animations. Both remove sources of noise. The element screenshots guide shows how to capture one component instead of the whole page.
Common mistakes
- Diffing JPEG or WebP. Lossy compression changes pixels on its own. Compare PNGs.
- Comparing images of different sizes. pixelmatch throws. A taller page means the layout changed; report that directly.
- Trusting the number alone. As the table shows, the count says how much moved, not whether it matters. Open the diff image.
- Full-page diffs only. One shift near the top marks everything below it. Diff components and regions too.
- Baselines from another machine. Different fonts or a different device pixel ratio fail every comparison.
Related terms
- Visual regression testing: the workflow the diff plugs into.
- prefers-reduced-motion: stop animations from changing pixels between runs.
- Wait strategies: capture when the page is ready, not half loaded.
- Device pixel ratio: a 2x baseline never matches a 1x capture.
For review and approval of baselines, read the visual regression testing guide. To get identical captures on every run, create a free account and use the request above.