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.
The request gallery
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
429withRetry-After. The script retried the429, 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:
- Motion.
browser.reduced_motion: truelets CSS that respects the media query skip to the end state. Carousels driven by JavaScript ignore it, so hide them. - Time. A fixed
browser.timezonemakes "today" render the same on both sides. Hide relative dates ("3 minutes ago") withpage.hide, which keeps their space so nothing below moves. - Third parties. Consent dialogs, ads and chat bubbles load at their own pace. Block them.
- Late data. If
networkidleis not enough, wait for the last element withwait.for_selector. A fixedwait.delayis a guess. - Scale. Keep
scale: 1for 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.author 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.