April 2026 · 8 min read
Screenshot-based website monitoring catches things that uptime monitors miss: visual regressions, broken layouts, changed content, competitor price updates, and defaced pages. In this tutorial we'll build a complete monitoring system from scratch using Node.js, SnapAPI for screenshots, S3 for storage, and Slack for alerts.
A cron job that runs every hour, captures screenshots of a list of URLs, compares each screenshot to the previous one using a pixel-difference algorithm, and sends a Slack notification with the before/after images when a significant change is detected. The whole system runs in under 200MB of RAM with no browser to manage.
Node.js 20+, an AWS S3 bucket (or any object storage), a Slack incoming webhook URL, and a SnapAPI key (free tier at snapapi.pics — 200 captures/month, no credit card).
mkdir site-monitor && cd site-monitor
npm init -y
npm install @aws-sdk/client-s3 node-cron sharp
// config.js
export default {
snapapiKey: process.env.SNAPAPI_KEY,
slackWebhook: process.env.SLACK_WEBHOOK_URL,
s3Bucket: process.env.S3_BUCKET,
changeThreshold: 0.02, // 2% pixel difference triggers alert
sites: [
{ name: 'Homepage', url: 'https://yoursite.com', width: 1280 },
{ name: 'Pricing', url: 'https://yoursite.com/pricing', width: 1280 },
{ name: 'Competitor', url: 'https://competitor.com', width: 1280, full_page: false },
],
};
// capture.js
import config from './config.js';
export async function captureScreenshot(site) {
const params = new URLSearchParams({
access_key: config.snapapiKey,
url: site.url,
format: 'png',
width: String(site.width || 1280),
full_page: site.full_page !== false ? 'true' : 'false',
});
const res = await fetch(
`https://api.snapapi.pics/v1/screenshot?${params}`
);
if (!res.ok) throw new Error(`SnapAPI error: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
// storage.js
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import config from './config.js';
const s3 = new S3Client({ region: 'us-east-1' });
export async function saveScreenshot(name, buffer) {
const key = `screenshots/${name}/${Date.now()}.png`;
await s3.send(new PutObjectCommand({
Bucket: config.s3Bucket,
Key: key,
Body: buffer,
ContentType: 'image/png',
}));
return key;
}
export async function getLatestScreenshot(name) {
try {
const key = `screenshots/${name}/latest.png`;
const res = await s3.send(new GetObjectCommand({ Bucket: config.s3Bucket, Key: key }));
const chunks = [];
for await (const chunk of res.Body) chunks.push(chunk);
return Buffer.concat(chunks);
} catch {
return null; // No previous screenshot
}
}
export async function saveLatest(name, buffer) {
await s3.send(new PutObjectCommand({
Bucket: config.s3Bucket,
Key: `screenshots/${name}/latest.png`,
Body: buffer,
ContentType: 'image/png',
}));
}
// diff.js
import sharp from 'sharp';
export async function computeDiff(bufA, bufB) {
// Resize both to same dimensions for comparison
const [imgA, imgB] = await Promise.all([
sharp(bufA).resize(1280, 800, { fit: 'contain' }).raw().toBuffer({ resolveWithObject: true }),
sharp(bufB).resize(1280, 800, { fit: 'contain' }).raw().toBuffer({ resolveWithObject: true }),
]);
const { width, height, channels } = imgA.info;
let diffPixels = 0;
const totalPixels = width * height;
for (let i = 0; i < imgA.data.length; i += channels) {
const rDiff = Math.abs(imgA.data[i] - imgB.data[i]);
const gDiff = Math.abs(imgA.data[i+1] - imgB.data[i+1]);
const bDiff = Math.abs(imgA.data[i+2] - imgB.data[i+2]);
if (rDiff + gDiff + bDiff > 30) diffPixels++;
}
return diffPixels / totalPixels; // Returns 0.0 to 1.0
}
// alert.js
import config from './config.js';
export async function sendSlackAlert(site, diffPct, beforeUrl, afterUrl) {
const pct = (diffPct * 100).toFixed(1);
await fetch(config.slackWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🚨 *${site.name}* changed by ${pct}%`,
attachments: [
{ title: 'Before', image_url: beforeUrl },
{ title: 'After', image_url: afterUrl },
],
}),
});
}
// monitor.js
import cron from 'node-cron';
import config from './config.js';
import { captureScreenshot } from './capture.js';
import { saveScreenshot, getLatestScreenshot, saveLatest } from './storage.js';
import { computeDiff } from './diff.js';
import { sendSlackAlert } from './alert.js';
async function checkSite(site) {
console.log(`Checking ${site.name}...`);
const current = await captureScreenshot(site);
const previous = await getLatestScreenshot(site.name);
if (previous) {
const diff = await computeDiff(previous, current);
console.log(` Diff: ${(diff * 100).toFixed(2)}%`);
if (diff > config.changeThreshold) {
const [beforeKey, afterKey] = await Promise.all([
saveScreenshot(`${site.name}/before`, previous),
saveScreenshot(`${site.name}/after`, current),
]);
const baseUrl = `https://${config.s3Bucket}.s3.amazonaws.com`;
await sendSlackAlert(site, diff, `${baseUrl}/${beforeKey}`, `${baseUrl}/${afterKey}`);
console.log(` ALERT sent for ${site.name}`);
}
}
await saveLatest(site.name, current);
}
// Run immediately on startup, then every hour
async function runChecks() {
for (const site of config.sites) {
try { await checkSite(site); }
catch (e) { console.error(`Failed ${site.name}:`, e.message); }
}
}
runChecks();
cron.schedule('0 * * * *', runChecks);
This monitor runs as a long-lived Node.js process. Deploy it on any small server — a $6/month Hetzner VPS or Fly.io machine is more than enough. Alternatively, convert it to a cron-triggered serverless function on AWS Lambda (using EventBridge scheduler) or Cloudflare Workers (using Cron Triggers). The SnapAPI call and S3 upload both work identically in serverless environments since they're plain HTTP calls with no native binaries.
Once the basic monitor is running, common extensions include: email alerts via Postmark or SendGrid alongside Slack, a simple web dashboard showing historical screenshots for each site, mobile viewport monitoring (add device: iPhone_15_Pro to your capture params), and competitor price tracking (use the extract endpoint instead of screenshot to pull specific text from competitor pages and alert when prices change).
Sign up at snapapi.pics — free tier gives 200 captures/month with no credit card. For a monitor checking 5 sites hourly, that's 3,600 captures/month — well within the $19/month Starter plan. The full source code for this tutorial is available on GitHub at github.com/Sleywill/snapapi-monitor-example.
Many visual regressions only appear on mobile. Add a second capture pass with a mobile device preset to catch responsive layout breakages that would be invisible in desktop screenshots. SnapAPI supports over 30 device presets including all recent iPhone and Android models. Pass device=iPhone_15_Pro to your screenshot call and you get a pixel-perfect mobile rendering without any additional browser configuration.
For sites where you care about specific text content rather than visual layout — competitor pricing pages, regulatory filings, job boards — use the extract endpoint instead of screenshot. Extract returns structured JSON from specific CSS selectors, making it trivial to compare previous and current values without any image processing:
// Extract-based price monitor
async function checkPrice(url, selector) {
const res = await fetch('https://api.snapapi.pics/v1/extract?' + new URLSearchParams({
access_key: process.env.SNAPAPI_KEY,
url,
selector,
fields: JSON.stringify({ price: '.price', stock: '.stock-status' }),
}));
const { data } = await res.json();
return data[0]; // { price: '$99.99', stock: 'In Stock' }
}
For low-frequency monitoring (daily or weekly), GitHub Actions provides free scheduled execution without any server to maintain:
# .github/workflows/monitor.yml
name: Site Monitor
on:
schedule:
- cron: '0 9 * * 1-5' # 9am weekdays
workflow_dispatch:
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: node monitor.js
env:
SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
Once you have screenshots stored in S3 with timestamps in the key path, you can build a simple historical gallery. Query S3 for all objects matching the site name prefix, sort by timestamp, and render them as an image timeline. This gives stakeholders a visual history of how any monitored page has evolved over time — invaluable for understanding when and how a regression was introduced.
The Slack webhook in this tutorial is just one alerting option. The same pattern works with PagerDuty (for critical production monitoring), email via SendGrid or Postmark, Microsoft Teams webhooks, Discord webhooks for developer teams, and Opsgenie for on-call rotation alerts. Pick the alerting destination that matches your existing incident response workflow — the screenshot capture and diff logic is identical regardless of where you send the alert.
For a monitor checking 10 sites every hour, you generate 7,200 screenshots per month — comfortably within SnapAPI's $19/month Starter plan (5,000 captures) if you reduce to every 2 hours, or the $79/month Pro plan (50,000) for hourly checks across up to 68 sites. S3 storage costs are negligible: at roughly 200KB per PNG screenshot, 7,200 captures per month is about 1.4GB — under $0.03/month in S3 storage costs.
The full source code for this monitoring system is straightforward to deploy. Get your free SnapAPI key at snapapi.pics — 200 captures per month, no credit card. For production monitoring workloads, the Starter plan at $19/month handles most small-to-medium site lists with hourly checks.