Visual Regression Testing Without Playwright or Chromatic: An API-First Approach for Solo Devs and Small Teams

Profile

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.

Why Visual Regression Testing Actually Matters

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.

Why Playwright and Chromatic Are Not Always the Right Choice

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:

  • Browsers need to be installed and kept in sync across CI and local machines
  • Snapshot files go into Git, which gets unwieldy at scale
  • Baseline management across branches requires discipline
  • Any environment rendering difference produces false positives, and they are annoying to debug

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.

The Tradeoffs of an API-First Approach

Before going further, this approach has real tradeoffs and they are worth being honest about.

CriteriaPlaywrightChromaticScreenshot API
Requires browser runtimeYesNo (cloud)No
Tests live deployed pagesYesNo (components only)Yes
Multi-viewport supportYes (manual config)LimitedYes (parallel requests)
Storybook dependencyNoYesNo
Image diff toolingBuilt-inBuilt-inBring your own
Baseline in GitYes (can be large)Managed by ChromaticYour choice
Cost at low volumeFreeFree tier limitedFree 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.

How ScreenshotAPI Fits Into a Visual Regression Workflow

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 significantly
  • incognito=true ensures every capture starts from a clean browser state with no cached sessions
  • width and height let you target specific viewport breakpoints
  • delay or wait_for_event handle pages with animations or async data loads
  • block_ads=true and no_cookie_banners=true keep your baselines clean and free of third-party content that shifts between renders
  • fresh=true bypasses caching so you always get a current render

The 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.

image

Building a Practical Regression Workflow

Here is what the workflow looks like in practice. It is four steps:

  1. Before your first deploy, capture baseline screenshots and store them somewhere your CI can reach
  2. On every subsequent deploy to staging, capture fresh screenshots of the same pages and viewports
  3. Run a pixel comparison between the new captures and the stored baselines
  4. Fail the build (or post a notification) if the diff exceeds your threshold

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.

Real Implementation

Capturing Screenshots in CI

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);
    }
  }
})();

Comparing Against Baselines

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);
}

Targeting Specific Components

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.png

Replace %23main-nav with the URL-encoded CSS selector for your component (#main-nav becomes %23main-nav).

image

Best Practices

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.

Common Mistakes

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.

When This Approach Does Not Make Sense

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.

Frequently Asked Questions

Can I use ScreenshotAPI for visual regression testing without Playwright?


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.

What is the difference between component-level and page-level visual testing?


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.

How do I handle dynamic content like timestamps or live prices in visual regression tests?


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.

Is a screenshot API approach reliable enough for production-grade visual testing?


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.