Visual Regression Testing API for CI Screenshots

Use a screenshot API as the capture step of visual regression testing. Render production and the PR preview with the same settings, then diff them in CI.

By Simon Chiu · Updated

Your test suite passes, and the pricing page still ships with the plan cards stacked on top of each other. Unit tests and end-to-end tests check behavior, not layout. Render the pages that matter twice, once on production and once on the pull request's preview, with every pixel-affecting option fixed, then diff the two PNGs in CI and fail the build above a threshold. RenderScreenshot does the rendering. The diff is a short script that you own. This page has the request, the script and the settings that keep the diff quiet.

It is part of the use cases section. For the definitions of baselines, thresholds and diff images, read the visual regression testing glossary entry.

What we offer, and what we do not

We want to be clear before you build on this. RenderScreenshot has no comparison feature yet. A hosted Visual Diff is on the roadmap, not in the API. What the API gives you today is the hard half of a visual test: a capture that comes out the same way on every run, whatever CI runner calls it. Your CI keeps the comparison, the threshold and the approval step.

Two ways to get a baseline

Approach Baseline Good for Weak spot
Two-origin diff (this page) Production, rendered in the same run Marketing sites, docs, apps with a public preview per PR Fails when production itself changes for content reasons, such as a new blog post on the home page
Stored baseline files PNG files committed to the repository Component galleries, Storybook, pages without a stable reference URL A renderer or font update fails every test until someone accepts new files

The two-origin diff removes the most common source of false failures in hosted capture: the browser build changes between the day the baseline was taken and today. Both images come from the same renderer in the same minute, so an engine change moves both of them. The website monitoring use case uses stored baselines instead, because a schedule has no second origin to compare with.

Each row is the body of a POST /v1/screenshot. The script sends the same body to both origins.

Page Body additions on top of the fixed settings Why
Home page with a carousel "page": {"hide": ["[data-testid=hero-carousel]"]} A rotating slide differs on every capture
Pricing page "page": {"hide": [".last-updated"]} A date string changes daily and is not a regression
Long docs page "capture": {"mode": "full_page", "scroll": {"simulate": true}} Lazy images below the fold load before the capture
Mobile layout "device": "iphone_14" Breakpoint bugs that a desktop viewport cannot show
Dark theme "browser": {"dark_mode": true} A theme is a separate baseline, never a variant of the light one

The fixed settings in every row are a 1280 x 800 viewport at scale: 1, PNG output, browser.reduced_motion: true, a fixed timezone and locale, blocked cookie banners, ads and chat widgets, and wait.until: "networkidle". The browser options and page modifications references list every field.

How it works

flowchart LR
  PR[Pull request] --> P[Preview deploy]
  P --> J[CI job: vrt.mjs]
  J -->|same body, production URL| R[RenderScreenshot]
  J -->|same body, preview URL| R
  R -->|two PNGs per route| J
  J --> D{pixelmatch}
  D -->|over threshold| F[Fail + diff artifact]
  D -->|under threshold| OK[Pass]

The script

The script reads routes.json, captures each route on both origins, pads full-page captures to the same height, writes base, head and diff PNGs to vrt-out/, and appends a table to the GitHub job summary. A changed page height counts as changed pixels, because a longer page is often the regression itself.

[
  { "path": "/", "hide": ["[data-testid=hero-carousel]"] },
  { "path": "/pricing", "hide": [".last-updated"] },
  { "path": "/docs", "fullPage": true }
]
// vrt.mjs: capture each route on two origins with identical settings and diff them.
// Node 22+. npm i [email protected] [email protected]
//   RS_API_KEY=... BASE_ORIGIN=https://www.example.com HEAD_ORIGIN=https://pr-123.preview.example.com node vrt.mjs
import { mkdir, readFile, writeFile, appendFile } from 'node:fs/promises';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const { RS_API_KEY, BASE_ORIGIN, HEAD_ORIGIN } = process.env;
const MAX_RATIO = Number(process.env.MAX_DIFF_RATIO ?? '0.002'); // 0.2% of pixels
if (!RS_API_KEY || !BASE_ORIGIN || !HEAD_ORIGIN) {
  console.error('Set RS_API_KEY, BASE_ORIGIN and HEAD_ORIGIN');
  process.exit(2);
}

// Every option that affects pixels is fixed here, so both sides render the same way.
function requestBody(url, route) {
  return {
    url,
    viewport: { width: 1280, height: 800, scale: 1 },
    output: { format: 'png' },
    browser: { reduced_motion: true, dark_mode: false, timezone: 'UTC', locale: 'en-US' },
    block: { cookie_banners: true, chat_widgets: true, ads: true },
    wait: { until: 'networkidle', timeout: 45 },
    page: { hide: route.hide ?? [] },
    ...(route.fullPage && { capture: { mode: 'full_page', scroll: { simulate: true } } }),
  };
}

async function capture(url, route, attempt = 1) {
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(requestBody(url, route)),
  });
  if (res.status === 429 && attempt < 4) {
    const wait = Number(res.headers.get('retry-after') ?? '5');
    await new Promise((r) => setTimeout(r, wait * 1000));
    return capture(url, route, attempt + 1);
  }
  if (!res.ok) throw new Error(`${url}: HTTP ${res.status} ${await res.text()}`);
  return PNG.sync.read(Buffer.from(await res.arrayBuffer()));
}

// Full-page captures can differ in height. Pad both to the same size so the
// diff shows the extra area as changed instead of crashing.
function padTo(img, width, height) {
  if (img.width === width && img.height === height) return img;
  const out = new PNG({ width, height });
  // Magenta never occurs by accident, so every padded pixel counts as changed.
  for (let i = 0; i < out.data.length; i += 4) out.data.set([255, 0, 255, 255], i);
  PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0);
  return out;
}

const routes = JSON.parse(await readFile('routes.json', 'utf8'));
await mkdir('vrt-out', { recursive: true });
const rows = [];

for (const route of routes) {
  const slug = route.path === '/' ? 'home' : route.path.replace(/^\/|\/$/g, '').replaceAll('/', '_');
  // Both sides in the same run: same renderer build, same fonts, same minute.
  const [baseRaw, headRaw] = await Promise.all([
    capture(new URL(route.path, BASE_ORIGIN).href, route),
    capture(new URL(route.path, HEAD_ORIGIN).href, route),
  ]);
  const width = Math.max(baseRaw.width, headRaw.width);
  const height = Math.max(baseRaw.height, headRaw.height);
  const base = padTo(baseRaw, width, height);
  const head = padTo(headRaw, width, height);
  const diff = new PNG({ width, height });
  const changed = pixelmatch(base.data, head.data, diff.data, width, height, { threshold: 0.1 });
  const ratio = changed / (width * height);

  await writeFile(`vrt-out/${slug}.base.png`, PNG.sync.write(base));
  await writeFile(`vrt-out/${slug}.head.png`, PNG.sync.write(head));
  await writeFile(`vrt-out/${slug}.diff.png`, PNG.sync.write(diff));
  const heightNote = baseRaw.height !== headRaw.height ? ` (height ${baseRaw.height} -> ${headRaw.height})` : '';
  rows.push({ path: route.path, ratio, failed: ratio > MAX_RATIO, heightNote });
  console.log(`${route.path}: ${(ratio * 100).toFixed(3)}% changed${heightNote}`);
}

const table = ['| Route | Changed | Result |', '|---|---|---|',
  ...rows.map((r) => `| \`${r.path}\` | ${(r.ratio * 100).toFixed(3)}%${r.heightNote} | ${r.failed ? 'FAIL' : 'pass'} |`)].join('\n');
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `## Visual diff\n\n${table}\n`);
if (rows.some((r) => r.failed)) process.exitCode = 1;

Tested on 2026-10-18 with Node 22.18.0, [email protected] and [email protected], against a local stub of our API that returns PNGs and one 429 with Retry-After. The script retried the 429, reported a changed route with exit code 1, and counted a 100-pixel height change as changed pixels. We did not run it against the live API for this page.

To run it in GitHub Actions, add a step after your preview deploy with RS_API_KEY from a secret, BASE_ORIGIN set to production and HEAD_ORIGIN set to the preview URL, then upload vrt-out/ with actions/upload-artifact. Getting that preview URL on time is the tricky part, and screenshots of preview deployments covers it host by host.

Settings that keep the diff quiet

Most false failures come from the page, not the renderer. Fix them in this order before you raise the threshold:

  1. Motion. browser.reduced_motion: true lets CSS that respects the media query skip to the end state. Carousels driven by JavaScript ignore it, so hide them.
  2. Time. A fixed browser.timezone makes "today" render the same on both sides. Hide relative dates ("3 minutes ago") with page.hide, which keeps their space so nothing below moves.
  3. Third parties. Consent dialogs, ads and chat bubbles load at their own pace. Block them.
  4. Late data. If networkidle is not enough, wait for the last element with wait.for_selector. A fixed wait.delay is a guess.
  5. Scale. Keep scale: 1 for diffs. A scale of 2 makes four times the pixels to compare and more antialiased edges, while the layout it shows is the same.

A threshold of 0.2% of pixels with pixelmatch's color threshold: 0.1 is a starting point, not a rule. Tune it per route after a week of green runs.

What it costs

Each route costs two renders per run: one for production, one for the preview. Failed renders, such as a timeout or a 429, cost nothing.

Suite Renders per month Plan that covers it
5 routes, 60 PR updates a month 600 Starter: 2,500 credits for $10
10 routes, 120 PR updates a month 2,400 Starter
15 routes x 2 themes, 150 PR updates a month 9,000 Growth: 12,000 credits for $40

At 9,000 renders a month on Growth, one render costs about $0.0044. You can halve the bill by rendering production once per day and reusing that render with cache.available for every PR of the day, at the cost of a baseline that may be a few hours old. The caching reference explains the cache key. Current prices are on the pricing page.

Limits to know

  • Requests per minute are counted per account, shared by all its keys. A large suite in a parallel CI matrix hits the rate limit quickly; the script waits for Retry-After.
  • The renderer cannot reach private networks. A preview behind a VPN will not work; previews behind a password can use network.auth or a bypass header.
  • A page has at most 60 seconds to load (wait.timeout).

Next steps

After merge, check that production renders what you reviewed with a post-deploy screenshot check. Then get a free API key and run vrt.mjs against your own production site and its latest preview.

Frequently asked questions

Does RenderScreenshot compare the images for me?
Not today. The API renders the screenshots. The comparison runs in your CI with a library such as pixelmatch. A hosted Visual Diff feature is on the roadmap, and this page will change when it ships.
Why compare against production instead of stored baseline files?
Both captures come from the same renderer in the same minute, so a browser or font update on our side changes both images equally. Stored baselines are better for pages that have no stable reference URL, such as a component gallery.
Which output format should a visual test use?
PNG. JPEG and WebP compression adds its own pixel differences, which show up as noise in the diff.
Can I send the visual test requests as one batch?
Not with this setup. The batch endpoint does not accept page.hide, network options or content checks, and a visual test needs page.hide. Send single requests in parallel, inside your plan's rate limit.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.