Website Monitoring with Screenshots and Pixel Diffs

Visual website monitoring with screenshots, content checks that fail on error text, and a pixel diff against a baseline, run on your own cron or CI schedule.

By Simon Chiu · Updated

An uptime check tells you that the server answered 200. It does not tell you that the hero image is broken, that a deploy pushed the checkout button below the fold, or that the pricing page now says "Contact us" where a price used to be. Capture each page you care about on a schedule, fail the capture when the page shows text it must not show, and compare the image with an accepted baseline to catch everything else. This page gives the requests, a tested Node script that does all three, and a GitHub Actions schedule to run it.

It is part of the use cases section. When the pages you watch belong to other companies, competitor monitoring adds element captures and price checks.

Three signals, one request

Signal How it fires What it catches Cost when it fires
Content check options.fail_if_contains or fail_if_missing returns 422 content_check_failed Error banners, maintenance pages, a missing price or button label 0 credits, because failed renders are free
Pixel diff Your code compares the PNG with a baseline Layout shifts, broken images, wrong colors, missing sections 1 credit, the render you already paid for
Page title X-Screenshot-Title differs from the last run A page swapped for a parked domain or an error template Comes with every successful render

Use all three. A content check is exact but only finds what you thought to list. A pixel diff finds anything visual but cannot say what it means. The title header costs nothing extra and catches the case where the whole page has been replaced.

Page type Request body additions Why
Marketing home page "options": {"fail_if_missing": ["Start free trial"]} The main call to action must exist
Status or health page "options": {"fail_if_contains": ["Degraded performance", "Major outage"]} The words that mean trouble, copied from the page's own templates
Pricing page "page": {"hide": [".last-updated"]}, "options": {"fail_if_missing": ["per month"]} Hide the date that changes every day, require the price unit
Mobile layout "device": "iphone_14_pro" Mobile breakage that desktop checks miss
Long landing page "capture": {"mode": "full_page", "scroll": {"simulate": true}} Lazy images below the fold load before the capture

All rows share a fixed viewport of 1280x800 (except the device row), block for cookie banners, ads and chat widgets, and browser.reduced_motion: true so animations rest in their end state. Text checks match the visible text exactly and are case-sensitive. The content checks guide explains how to pick phrases that do not match by accident.

How it works

flowchart LR
  S[Schedule: cron, GitHub Actions,<br/>Workers cron] --> M[monitor.mjs]
  M -->|POST /v1/screenshot<br/>checks + hide + block| R[RenderScreenshot]
  R -->|422 content_check_failed| A[Alert]
  R -->|PNG| D{pixelmatch vs<br/>baseline}
  D -->|over threshold| A
  D -->|under threshold| OK[No alert]
  A --> N[Failed job, email,<br/>chat message]

RenderScreenshot renders and checks. It does not keep a schedule or a baseline. That split is deliberate: your baseline is a file you approve, in a repository you control, and the schedule lives next to the rest of your jobs.

The monitor script

pages.json lists what to watch:

[
  { "name": "home", "url": "https://example.com/", "mustContain": ["Example Domain"], "mustNotContain": ["Service Unavailable"] },
  { "name": "pricing", "url": "https://example.com/pricing", "hide": [".last-updated"], "mustContain": ["per month"] }
]

The script captures each page in turn, turns a content check failure into an alert, and diffs the rest:

// monitor.mjs: capture each page, run content checks, and pixel-diff against an accepted baseline.
// Node 22+, npm i pixelmatch@7 pngjs@7
//   RS_API_KEY=... node monitor.mjs            -> exit 0 no change, 2 change found, 1 error
//   RS_API_KEY=... node monitor.mjs --accept   -> store the current captures as the new baselines
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const ACCEPT = process.argv.includes('--accept');
const MAX_CHANGED = 0.002; // alert when more than 0.2% of pixels differ; tune per page
const pages = JSON.parse(await readFile('pages.json', 'utf8'));

async function capture(page) {
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: page.url,
      viewport: { width: 1280, height: 800 },
      wait: { until: 'networkidle', timeout: 30 },
      block: { cookie_banners: true, ads: true, chat_widgets: true },
      browser: { reduced_motion: true },
      page: { hide: page.hide ?? [] }, // clocks, carousels, "last updated" lines
      options: { fail_if_missing: page.mustContain ?? [], fail_if_contains: page.mustNotContain ?? [] },
      output: { format: 'png' },
    }),
    signal: AbortSignal.timeout(60_000),
  });
  if (res.ok) return { png: PNG.sync.read(Buffer.from(await res.arrayBuffer())) };
  const { error } = await res.json().catch(() => ({ error: {} }));
  if (error?.code === 'content_check_failed') return { contentAlert: error.message };
  throw new Error(`${page.name}: HTTP ${res.status} ${error?.code ?? ''}`);
}

function compare(baseline, current) {
  if (baseline.width !== current.width || baseline.height !== current.height) return { sizeChanged: true };
  const { width, height } = current;
  const diff = new PNG({ width, height });
  const changed = pixelmatch(baseline.data, current.data, diff.data, width, height, { threshold: 0.1 });
  return { ratio: changed / (width * height), diff };
}

await mkdir('baselines', { recursive: true });
await mkdir('diffs', { recursive: true });
const alerts = [];
let failed = false;
for (const page of pages) {
  try {
    const result = await capture(page);
    if (result.contentAlert) { alerts.push(`${page.name}: ${result.contentAlert}`); continue; }
    const file = `baselines/${page.name}.png`;
    if (ACCEPT) { await writeFile(file, PNG.sync.write(result.png)); continue; }
    const baseline = await readFile(file).then((b) => PNG.sync.read(b)).catch(() => null);
    if (!baseline) { alerts.push(`${page.name}: no baseline, run with --accept`); continue; }
    const cmp = compare(baseline, result.png);
    if (cmp.sizeChanged) { alerts.push(`${page.name}: page size changed`); continue; }
    if (cmp.ratio > MAX_CHANGED) {
      await writeFile(`diffs/${page.name}.png`, PNG.sync.write(cmp.diff));
      alerts.push(`${page.name}: ${(cmp.ratio * 100).toFixed(2)}% of pixels changed, see diffs/${page.name}.png`);
    }
  } catch (err) {
    console.error(err.message);
    failed = true;
  }
}
alerts.forEach((a) => console.log('ALERT', a));
process.exitCode = failed ? 1 : alerts.length ? 2 : 0;

Tested on 2026-10-18 with Node.js 22.18.0, pixelmatch 7.2.0 and pngjs 7.0.0, against a local stub that returns the API's documented responses. With no baselines it exited 2 and asked for --accept. With identical images it exited 0. With a changed block in the image it wrote diffs/home.png and exited 2. With a missing required phrase it reported the stub's content_check_failed message. A bad API key made it exit 1. We did not call the production API for this page.

Three choices keep alerts useful. The baseline only changes when a person runs --accept, so a slow drift cannot reset it. The exit code separates "something changed" (2) from "the monitor broke" (1), so you can route them to different people. And requests go one at a time, which keeps a long list under your account's per-minute rate limit, shared by all your keys.

Run it on a schedule

Scheduler Good for Watch out for
crontab on a server you already run Any interval, baselines on local disk The server is now part of the monitor
Scheduled GitHub Actions workflow Baselines reviewed in pull requests, diffs as artifacts Cron times are UTC, runs can start late under load, and GitHub turns schedules off in public repositories after 60 days without activity
Cloudflare Workers cron trigger Content checks only, no servers The script above uses Node file and PNG libraries, so keep the pixel diff on Node

This workflow runs the monitor every hour and keeps the diff images when it finds a change:

name: page-monitor
on:
  schedule:
    - cron: '17 * * * *' # minute 17 of every hour, UTC
  workflow_dispatch:
jobs:
  monitor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: node monitor.mjs
        env:
          RS_API_KEY: ${{ secrets.RS_API_KEY }}
      - if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: diffs
          path: diffs/

Commit baselines/ to the repository. When a change is intended, run node monitor.mjs --accept on a branch and merge the new baselines through review. The GitHub Actions integration covers secrets and the CLI if you prefer a shell step.

Tuning the noise

Most false alerts come from parts of the page that change on their own. Fix the cause before you raise the threshold:

  • Clocks, dates, counters and rotating testimonials: add their selectors to hide. Hide keeps the space, so the layout below does not move.
  • Consent dialogs and chat bubbles: block.cookie_banners and block.chat_widgets.
  • Animations: browser.reduced_motion lets CSS that respects the setting skip to the end state.
  • Late content: wait.for_selector on the last element to load is steadier than a fixed delay. The wait options list the modes.
  • A/B tests: monitor with a cookie that pins the variant, sent in network.cookies.

The pixel diff glossary entry explains how threshold (per-pixel color tolerance) differs from the changed-pixel ratio the script alerts on.

What it costs

Every passing capture is 1 credit, and every failed content check is free.

Schedule Renders per month Plan that covers it
20 pages every 6 hours about 2,480 Starter, 2,500 credits for $10
15 pages every hour about 11,160 Growth, 12,000 credits for $40
50 pages every hour about 37,200 Scale, 60,000 credits for $100

Fifteen pages every hour comes to about $0.0036 per check. Check less often for pages that rarely change, and keep the hourly runs for checkout and sign-up. Current prices are on the pricing page.

Next steps

To post a dashboard to your team instead of checking a page, see dashboard snapshots to Slack. Then get a free API key, write your pages.json, and run node monitor.mjs --accept to take your first baselines.

Frequently asked questions

Does RenderScreenshot run the schedule for me?
No. There is no scheduled-capture feature. You run the clock with crontab, a scheduled GitHub Actions workflow or a Cloudflare Workers cron trigger, and each run calls the API.
Why use content checks if I already compare screenshots?
A pixel diff tells you that something changed, not what. A content check names the problem, such as a missing price or a maintenance notice. It also fails before an image comes back, and a failed render costs no credits.
How do I stop false alerts from clocks, carousels and ads?
Hide the moving parts with page.hide, block ads and chat widgets, set browser.reduced_motion, keep the viewport fixed, and alert only above a changed-pixel ratio that you tune per page.
Can I use cache reuse to save credits on monitoring runs?
No. A monitoring request is the same every run, so with cache.available the API would return the earlier image and you would never see a change. Leave cache reuse off.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.