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.
The request gallery
| 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_bannersandblock.chat_widgets. - Animations:
browser.reduced_motionlets CSS that respects the setting skip to the end state. - Late content:
wait.for_selectoron 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.