Written By Hanzala Saleem
Updated At June 22, 2026 | 8 min read
Visual regression testing sounds simple in theory: take a screenshot before a deployment, take one after, compare them. Catch the broken layout before your users do.
In practice, the tooling gets heavy fast. Playwright needs a browser cluster to run consistently across environments. Chromatic requires Storybook. Percy charges per snapshot. Applitools is priced for enterprise. For a solo developer or a two-person team shipping a SaaS product, none of these feel like the right fit.
There is a lighter path. If you can make an HTTP request, you can build a visual regression workflow that runs on every deploy, covers multiple viewports, and does not require you to manage a single browser process.
Unit tests and integration tests tell you that your code works. They do not tell you that your checkout button shifted 40px to the left, or that your pricing table collapsed on mobile after a CSS dependency update.
Visual bugs are the ones that make it into production. A customer support ticket reading "the page looks broken on my phone" costs more time to diagnose than a failing test ever would. Visual regression testing is the layer that catches what functional tests miss.
The reason many developers skip it is not that they disagree with this. It is that the setup cost feels disproportionate for the problem being solved.
Playwright's built-in toHaveScreenshot() works well if you already have a full Playwright test suite. But if you just want screenshot-based regression checks on deployment, you are taking on a lot:
Chromatic is genuinely excellent if your frontend is built with Storybook. If it is not, Chromatic does not apply. It tests isolated components, not your live staging environment.
The tools are good. They are just built for different contexts than a small team that wants to verify their deployed app looks right across three viewport sizes after every push.
Before going further, this approach has real tradeoffs and they are worth being honest about.
| Criteria | Playwright | Chromatic | Screenshot API |
|---|---|---|---|
| Requires browser runtime | Yes | No (cloud) | No |
| Tests live deployed pages | Yes | No (components only) | Yes |
| Multi-viewport support | Yes (manual config) | Limited | Yes (parallel requests) |
| Storybook dependency | No | Yes | No |
| Image diff tooling | Built-in | Built-in | Bring your own |
| Baseline in Git | Yes (can be large) | Managed by Chromatic | Your choice |
| Cost at low volume | Free | Free tier limited | Free tier available |
The one thing you trade away with an API-based approach is the tight framework integration. There is no expect(page).toHaveScreenshot() assertion. You write the comparison logic yourself, or you reach for a lightweight library like pixelmatch or resemblejs. That is a few lines of code, not a project.
What you gain is simplicity: no browser binaries, no snapshot files in Git, consistent rendering in every environment, and the ability to test your actual deployed app at real URLs.
ScreenshotAPI runs every capture inside an isolated Chromium instance. You send an HTTP request, it renders the page, returns a PNG. There are no browsers to install on your CI runner.
A few parameters are particularly useful for regression testing:
selector captures a specific DOM element rather than the full page, which reduces diff noise significantlyincognito=true ensures every capture starts from a clean browser state with no cached sessionswidth and height let you target specific viewport breakpointsdelay or wait_for_event handle pages with animations or async data loadsblock_ads=true and no_cookie_banners=true keep your baselines clean and free of third-party content that shifts between rendersfresh=true bypasses caching so you always get a current renderThe endpoint is https://shot.screenshotapi.net/v3/screenshot with a token parameter for authentication. You can see the full API reference for the complete parameter list.

Here is what the workflow looks like in practice. It is four steps:
The baseline storage is flexible. An S3 bucket, a folder in your repo for small projects, or any object storage that your CI can read and write. ScreenshotAPI also supports direct cloud storage integration with S3, Google Cloud, and Wasabi if you want the captures sent directly to your bucket.
This Node.js script captures three pages at two viewports each, storing them as PNG files. Run it in your CI pipeline after every deployment to staging.
// capture-screenshots.js
const fs = require("fs");
const https = require("https");
const path = require("path");
const API_KEY = process.env.SCREENSHOTAPI_KEY;
const BASE_URL = process.env.STAGING_URL; // e.g. https://staging.yourapp.com
const pages = ["/", "/pricing", "/dashboard"];
const viewports = [
{ width: 1280, height: 800, label: "desktop" },
{ width: 375, height: 812, label: "mobile" },
];
async function capture(url, width, height, label, pagePath) {
const filename = `${label}${pagePath.replace(/\//g, "-")}.png`;
const apiUrl =
`https://shot.screenshotapi.net/v3/screenshot` +
`?token=${API_KEY}` +
`&url=${encodeURIComponent(url)}` +
`&width=${width}` +
`&height=${height}` +
`&output=image` +
`&file_type=png` +
`&block_ads=true` +
`&no_cookie_banners=true` +
`&incognito=true` +
`&fresh=true`;
return new Promise((resolve, reject) => {
const dest = fs.createWriteStream(path.join("screenshots", filename));
https.get(apiUrl, (res) => {
res.pipe(dest);
dest.on("finish", () => {
console.log(`Captured: ${filename}`);
resolve(filename);
});
}).on("error", reject);
});
}
(async () => {
fs.mkdirSync("screenshots", { recursive: true });
for (const page of pages) {
for (const vp of viewports) {
await capture(`${BASE_URL}${page}`, vp.width, vp.height, vp.label, page);
}
}
})();Once you have captured screenshots, compare them with pixelmatch. Install it with npm install pixelmatch pngjs.
// compare.js
const fs = require("fs");
const { PNG } = require("pngjs");
const pixelmatch = require("pixelmatch");
const path = require("path");
const THRESHOLD = 0.1; // 10% pixel difference tolerance
const DIFF_DIR = "diffs";
const BASELINE_DIR = "baselines";
const CURRENT_DIR = "screenshots";
fs.mkdirSync(DIFF_DIR, { recursive: true });
const files = fs.readdirSync(CURRENT_DIR).filter((f) => f.endsWith(".png"));
let failed = 0;
for (const file of files) {
const baselinePath = path.join(BASELINE_DIR, file);
const currentPath = path.join(CURRENT_DIR, file);
if (!fs.existsSync(baselinePath)) {
console.log(`No baseline for ${file}. Copying as new baseline.`);
fs.copyFileSync(currentPath, baselinePath);
continue;
}
const img1 = PNG.sync.read(fs.readFileSync(baselinePath));
const img2 = PNG.sync.read(fs.readFileSync(currentPath));
const { width, height } = img1;
const diff = new PNG({ width, height });
const numDiff = pixelmatch(img1.data, img2.data, diff.data, width, height, {
threshold: THRESHOLD,
});
const diffPercent = (numDiff / (width * height)) * 100;
if (diffPercent > 1) {
console.error(`FAIL: ${file} has ${diffPercent.toFixed(2)}% pixel diff`);
fs.writeFileSync(path.join(DIFF_DIR, file), PNG.sync.write(diff));
failed++;
} else {
console.log(`PASS: ${file}`);
}
}
if (failed > 0) {
console.error(`${failed} visual regression(s) detected.`);
process.exit(1);
}For component-level regression testing, use the selector parameter to capture only the element you care about. This produces smaller images and more targeted diffs.
# Capture only the navigation bar
curl "https://shot.screenshotapi.net/v3/screenshot\
?token=YOUR_API_KEY\
&url=https://staging.yourapp.com\
&selector=%23main-nav\
&output=image\
&file_type=png\
&incognito=true\
&fresh=true" \
-o screenshots/nav-desktop.pngReplace %23main-nav with the URL-encoded CSS selector for your component (#main-nav becomes %23main-nav).

Store baselines outside Git for anything beyond a handful of pages. PNG files add up quickly. Use an S3 bucket or equivalent. Your CI uploads the current captures, downloads the baselines for comparison, and uploads new baselines on approval.
Run captures after your app has fully loaded. Use wait_for_event=networkidle or add a delay parameter for pages with animations or async data loading. A race condition in your screenshot is worse than a slower test.
Use incognito=true consistently. This prevents session state from leaking between runs, which gives you clean and reproducible baselines. Without it, a captured session cookie on one run can cause layout differences on the next.
Set a reasonable diff threshold. Anti-aliasing, sub-pixel rendering, and font hinting can cause minor pixel differences even on identical pages. A threshold of around 0.5-1% of total pixels avoids noise without missing real regressions.
Capture components, not just full pages. Full-page diffs catch layout regressions but produce a lot of false positives for any dynamic content. Pairing page-level captures with component-level selector captures gives you better signal.
Testing only one viewport. A layout that looks fine at 1280px can be completely broken at 375px. Always include mobile in your baseline set.
Forgetting to block dynamic content. Third-party chat widgets, ads, live exchange rates, or countdown timers will cause your baselines to fail on every run. Use block_ads=true, no_cookie_banners=true, and block_chat_widgets=true to keep your captures deterministic.
Taking baselines from production. Capture baselines from staging in the same environment where regression checks run. Comparing production against staging introduces environment differences that have nothing to do with your code.
Storing baselines locally on CI. Ephemeral CI runners lose local state between runs. Your baselines will disappear. Store them in object storage.
If your team is already invested in Storybook and building a component library, Chromatic is genuinely the better tool. It was designed for that workflow.
If you need to test interaction states, hover effects, form validation flows, or anything that requires simulating user input before capturing, you need Playwright or Cypress with screenshot assertions. An API that captures a URL cannot simulate clicks.
For teams with more than five or six engineers working on the frontend at the same time, you will also want a shared review dashboard rather than image files in a storage bucket. Tools like Percy or Chromatic handle approval workflows in a way this approach does not.
The API-first approach is best suited for: solo developers, small teams, projects without Storybook, teams that want to test their live staging environment rather than isolated components, and anyone who wants to add a visual layer to their CI without installing browser dependencies.
Yes. ScreenshotAPI captures screenshots via HTTP requests with no browser runtime required on your end. Combined with a pixel comparison library like pixelmatch, you can build a full visual regression workflow that runs in any CI environment without browser installation.
Page-level testing captures the full rendered page and catches layout regressions across the whole UI. Component-level testing uses a CSS selector to capture a specific element, producing smaller images and more targeted diffs with fewer false positives. Both have a place in a complete testing strategy.
Use ScreenshotAPI's block_ads=true and no_cookie_banners=true parameters to eliminate third-party noise. For your own dynamic content, CSS injection (css parameter) can hide or stabilize elements before capture. Alternatively, run regression tests on a version of your app with mocked data.
Reliability depends on rendering consistency. ScreenshotAPI runs all captures in isolated Chromium instances with a fixed configuration, which eliminates the cross-machine rendering differences that make Playwright snapshots flaky in CI. For teams that want to test live URLs without managing browser infrastructure, it is a practical and stable option.