How to Catch Visual Regressions on Every Deploy with Screenshot Diffs
CSS changes break layouts. A z-index tweak hides a button. A font change shifts text wrapping. Unit tests don't catch any of this — and by the time a user reports it, the deploy is already live.
Visual regression testing captures screenshots before and after each deploy and diffs them. If pixels changed unexpectedly, the build fails.
The usual setup: Playwright or Puppeteer in CI, with Docker to run a browser. It works but adds 5-10 minutes to build time and a heavyweight container. Here's a lighter approach: two PageBolt API calls, one pixel diff, no browser in CI.
The workflow
deploy to staging → screenshot staging → compare to baseline → fail if diff > threshold → deploy to production
GitHub Actions workflow
# .github/workflows/visual-regression.yml
name: Visual regression
on:
pull_request:
branches: [main]
jobs:
visual-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm install pixelmatch pngjs
- name: Wait for staging deploy
run: |
for i in $(seq 1 30); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ vars.STAGING_URL }})
[ "$STATUS" = "200" ] && break
echo "Waiting for staging... ($i/30)"
sleep 10
done
- name: Run visual diff
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
STAGING_URL: ${{ vars.STAGING_URL }}
PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
run: node scripts/visual-diff.js
- name: Upload diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diff-results
path: diff-output/
Visual diff script
// scripts/visual-diff.js
import fs from "fs/promises";
import path from "path";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
const PAGEBOLT_API_KEY = process.env.PAGEBOLT_API_KEY;
const STAGING_URL = process.env.STAGING_URL;
const PRODUCTION_URL = process.env.PRODUCTION_URL;
// Pages to check on every deploy
const PAGES = [
{ name: "home", path: "/" },
{ name: "pricing", path: "/pricing" },
{ name: "login", path: "/login" },
{ name: "dashboard", path: "/dashboard" },
{ name: "docs", path: "/docs" },
];
// Max allowed pixel difference (0–1, fraction of total pixels)
const DIFF_THRESHOLD = 0.02; // 2%
async function screenshot(baseUrl, pagePath) {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: `${baseUrl}${pagePath}`,
fullPage: false,
blockBanners: true,
blockAds: true,
}),
});
return Buffer.from(await res.arrayBuffer());
}
function diffImages(baselineBuffer, currentBuffer) {
const baseline = PNG.sync.read(baselineBuffer);
const current = PNG.sync.read(currentBuffer);
const { width, height } = baseline;
const diff = new PNG({ width, height });
const changedPixels = pixelmatch(
baseline.data,
current.data,
diff.data,
width,
height,
{ threshold: 0.1 }
);
const totalPixels = width * height;
const diffRatio = changedPixels / totalPixels;
return { changedPixels, totalPixels, diffRatio, diffBuffer: PNG.sync.write(diff) };
}
async function main() {
await fs.mkdir("diff-output", { recursive: true });
let failed = false;
for (const page of PAGES) {
console.log(`Checking ${page.name} (${page.path})...`);
const [baseline, current] = await Promise.all([
screenshot(PRODUCTION_URL, page.path),
screenshot(STAGING_URL, page.path),
]);
const { changedPixels, totalPixels, diffRatio, diffBuffer } = diffImages(baseline, current);
const pct = (diffRatio * 100).toFixed(2);
if (diffRatio > DIFF_THRESHOLD) {
console.error(`❌ ${page.name}: ${pct}% changed (${changedPixels}/${totalPixels} pixels)`);
await fs.writeFile(`diff-output/${page.name}-baseline.png`, baseline);
await fs.writeFile(`diff-output/${page.name}-current.png`, current);
await fs.writeFile(`diff-output/${page.name}-diff.png`, diffBuffer);
failed = true;
} else {
console.log(`✅ ${page.name}: ${pct}% changed (within threshold)`);
}
}
if (failed) {
console.error("\nVisual regression detected. Check diff-output/ artifacts.");
process.exit(1);
}
console.log("\nAll pages within threshold. ✓");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Update baselines intentionally
When you make a deliberate design change, update the baseline:
# .github/workflows/update-baselines.yml
name: Update visual baselines
on:
workflow_dispatch:
inputs:
pages:
description: "Comma-separated page names to update (or 'all')"
default: "all"
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update baselines
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
run: node scripts/update-baselines.js ${{ github.event.inputs.pages }}
- name: Commit updated baselines
run: |
git config user.email "ci@yourapp.com"
git config user.name "CI Bot"
git add baselines/
git diff --staged --quiet || git commit -m "chore: update visual baselines"
git push
Store baselines in the repo (small teams) or S3 (large teams)
// scripts/update-baselines.js — saves screenshots to baselines/
import fs from "fs/promises";
const pages = process.argv[2] === "all"
? PAGES
: PAGES.filter((p) => process.argv[2].split(",").includes(p.name));
await fs.mkdir("baselines", { recursive: true });
for (const page of pages) {
const image = await screenshot(process.env.PRODUCTION_URL, page.path);
await fs.writeFile(`baselines/${page.name}.png`, image);
console.log(`Updated baseline: ${page.name}`);
}
Post diff summary to PR
- name: Post diff summary to PR
if: failure()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('diff-output/results.json'));
const failed = results.filter(r => r.failed);
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Visual regression detected\n\n${failed.map(r =>
`- **${r.name}**: ${r.pct}% changed (threshold: 2%)`
).join('\n')}\n\nSee diff artifacts in the Actions run.`
});
No browser in CI, no Docker container, no Playwright setup. Two HTTP calls per page, one pixel diff in Node — the whole check runs in under 30 seconds for five pages.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)