Uptime monitoring tells you if a site is responding. But it doesn't tell you if the page looks right. A site can return HTTP 200 while showing a blank page, broken layout, missing images, or stale content. Visual monitoring with screenshots catches what HTTP status codes miss.
This guide shows you how to build an automated visual monitoring system that captures screenshots on a schedule, compares them with pixel-level diffing, and alerts you when something changes.
System Architecture
A visual monitoring system has four components:
- Scheduler: Triggers captures at regular intervals (cron, BullMQ, cloud scheduler)
- Capture engine: Takes screenshots of target URLs (browser or API)
- Diff engine: Compares current screenshot with the baseline using pixel matching
- Alert system: Notifies when visual diff exceeds threshold (Slack, email, webhook)
Step 1 — Screenshot Capture
Using SnapAPI (Recommended)
import fs from 'fs';
async function captureScreenshot(url, device = null) {
const body = {
url,
format: 'png',
full_page: true,
block_ads: true,
};
if (device) body.device = device;
const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'X-Api-Key': process.env.SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`Capture failed: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
Step 2 — Pixel Diff Comparison
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
function compareScreenshots(baseline, current) {
const img1 = PNG.sync.read(baseline);
const img2 = PNG.sync.read(current);
// Handle size differences
const width = Math.max(img1.width, img2.width);
const height = Math.max(img1.height, img2.height);
// Pad images to same size if needed
const padded1 = padImage(img1, width, height);
const padded2 = padImage(img2, width, height);
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(
padded1.data, padded2.data, diff.data,
width, height,
{ threshold: 0.1 }
);
const totalPixels = width * height;
const diffPercent = (mismatchedPixels / totalPixels) * 100;
return {
mismatchedPixels,
totalPixels,
diffPercent: diffPercent.toFixed(2),
diffImage: PNG.sync.write(diff),
};
}
function padImage(img, targetWidth, targetHeight) {
if (img.width === targetWidth && img.height === targetHeight) return img;
const padded = new PNG({ width: targetWidth, height: targetHeight, fill: true });
PNG.bitblt(img, padded, 0, 0, img.width, img.height, 0, 0);
return padded;
}
Step 3 — Scheduled Monitoring
import cron from 'node-cron';
import path from 'path';
const TARGETS = [
{ url: 'https://myapp.com', name: 'homepage', threshold: 1.0 },
{ url: 'https://myapp.com/pricing', name: 'pricing', threshold: 0.5 },
{ url: 'https://myapp.com/dashboard', name: 'dashboard', threshold: 2.0 },
];
const BASELINE_DIR = './baselines';
const DIFF_DIR = './diffs';
// Run every 30 minutes
cron.schedule('*/30 * * * *', async () => {
console.log(`[${new Date().toISOString()}] Starting visual check...`);
for (const target of TARGETS) {
try {
const current = await captureScreenshot(target.url);
const baselinePath = path.join(BASELINE_DIR, `${target.name}.png`);
if (!fs.existsSync(baselinePath)) {
// First run — save as baseline
fs.writeFileSync(baselinePath, current);
console.log(` [${target.name}] Baseline created`);
continue;
}
const baseline = fs.readFileSync(baselinePath);
const result = compareScreenshots(baseline, current);
if (parseFloat(result.diffPercent) > target.threshold) {
// Visual change detected!
const diffPath = path.join(DIFF_DIR,
`${target.name}-${Date.now()}.png`);
fs.writeFileSync(diffPath, result.diffImage);
await sendAlert({
target: target.name,
url: target.url,
diffPercent: result.diffPercent,
diffImage: diffPath,
});
console.log(` [${target.name}] CHANGE: ${result.diffPercent}%`);
} else {
console.log(` [${target.name}] OK (${result.diffPercent}% diff)`);
}
} catch (err) {
console.error(` [${target.name}] Error: ${err.message}`);
}
}
});
Step 4 — Alert System
async function sendAlert({ target, url, diffPercent, diffImage }) {
// Slack webhook
await fetch(process.env.SLACK_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Visual change detected on *${target}*`,
blocks: [{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Visual Change Alert*\n` +
`Page: ${url}\n` +
`Change: ${diffPercent}%\n` +
`Time: ${new Date().toISOString()}`,
},
}],
}),
});
}
Monitoring Use Cases
Competitor Price Monitoring
Screenshot competitor pricing pages daily and alert when they change. Combine with SnapAPI's /v1/extract endpoint to also pull structured pricing data for analysis.
Deployment Verification
After every deployment, capture screenshots across key pages and device sizes. Compare against pre-deployment baselines to catch visual regressions before users see them.
Content Change Detection
Monitor news sites, job boards, or government pages for content updates. Use /v1/scrape for text-level diffing alongside visual comparison.
Brand Compliance
Monitor franchise or partner websites to ensure they follow brand guidelines. Screenshot multiple URLs daily and flag visual deviations.
SLA Documentation
Capture screenshots of third-party services your app depends on. Build a visual archive that documents uptime and visual state over time.
Multi-Device Monitoring
const DEVICES = ['iphone-15-pro', 'ipad-pro-12.9', 'macbook-pro-16'];
async function monitorAcrossDevices(url, name) {
const results = [];
for (const device of DEVICES) {
const screenshot = await captureScreenshot(url, device);
const baselinePath = `./baselines/${name}-${device}.png`;
if (fs.existsSync(baselinePath)) {
const baseline = fs.readFileSync(baselinePath);
const diff = compareScreenshots(baseline, screenshot);
results.push({ device, ...diff });
}
fs.writeFileSync(baselinePath, screenshot);
}
return results;
}
Monitor Any Website Visually
Screenshot any URL across 30+ devices. No browser infrastructure needed. 200 free requests/month.
Get Your Free API Key