š Table of Contents
Why Visual Change Detection?
Traditional website monitoring checks whether a page returns an HTTP 200 status code. But that doesn't tell you if your checkout button disappeared, your hero image broke, or a third-party ad injected unwanted content into your layout.
Visual change detection goes beyond HTTP status codes. It captures an actual screenshot of the rendered page and compares it against a known-good baseline. If the pixels don't match, you know something changed ā even if the server returned a perfect 200.
Common use cases include:
- E-commerce: Detect broken product images, price display errors, or layout shifts after deployments
- Compliance: Monitor regulated pages for unauthorized changes
- Competitor tracking: Get notified when a competitor updates their landing page or pricing
- Brand protection: Detect defacement, phishing clones, or unauthorized modifications
- QA automation: Catch visual regressions in staging environments before they hit production
Architecture Overview
Our change detector has four components:
- Screenshot Capture ā Takes a screenshot of the target URL using a Screenshot API
- Image Storage ā Saves screenshots locally with timestamps for comparison and audit trails
- Pixel Comparison ā Compares the new screenshot against the previous baseline using
pixelmatch - Alert System ā Sends a notification (Slack, email, webhook) when meaningful changes are detected
// Project structure
website-change-detector/
āāā index.js // Main orchestrator
āāā capture.js // Screenshot capture module
āāā compare.js // Image comparison logic
āāā alert.js // Notification system
āāā config.json // URLs to monitor + thresholds
āāā screenshots/ // Stored baseline & new captures
āāā example.com/
ā āāā baseline.png
ā āāā latest.png
ā āāā diff.png
Step 1: Capturing Baseline Screenshots
The first step is capturing a screenshot of each URL you want to monitor. We'll use SnapAPI for reliable, consistent renders ā no need to manage headless Chromium instances on your server.
// capture.js
const fs = require('fs');
const path = require('path');
const API_BASE = 'https://snapapi.alterra-solutions.com/v1/screenshot';
const API_KEY = process.env.SNAPAPI_KEY || 'YOUR_API_KEY';
async function captureScreenshot(url, outputPath) {
const params = new URLSearchParams({
url,
api_key: API_KEY,
width: '1280',
height: '720',
full_page: 'true',
format: 'png',
delay: '2000', // Wait for lazy-loaded content
block_ads: 'true', // Remove ad noise from comparisons
response: 'binary',
});
const response = await fetch(`${API_BASE}?${params}`);
if (!response.ok) {
throw new Error(`Screenshot failed: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
// Ensure directory exists
const dir = path.dirname(outputPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outputPath, buffer);
console.log(`šø Captured: ${url} ā ${outputPath}`);
return outputPath;
}
module.exports = { captureScreenshot };
Why use an API instead of Puppeteer? Running headless Chrome on a server requires 500MB+ of RAM, system dependencies, and font libraries. A Screenshot API abstracts all this complexity into a single HTTP call ā and you get consistent renders across environments.
Step 2: Pixel-Level Comparison
Now comes the core of the change detector. We'll use the pixelmatch library (the same one
Playwright uses internally for visual testing) to compare two PNG images pixel by pixel.
// compare.js
const fs = require('fs');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
function compareScreenshots(baselinePath, latestPath, diffPath) {
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const latest = PNG.sync.read(fs.readFileSync(latestPath));
// Handle size differences (page height may have changed)
const width = Math.max(baseline.width, latest.width);
const height = Math.max(baseline.height, latest.height);
// Resize both images to the same dimensions
const baselineResized = resizeImage(baseline, width, height);
const latestResized = resizeImage(latest, width, height);
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(
baselineResized.data,
latestResized.data,
diff.data,
width,
height,
{
threshold: 0.1, // Color sensitivity (0 = exact, 1 = lenient)
includeAA: false, // Ignore anti-aliasing differences
diffColor: [255, 0, 0], // Highlight changes in red
}
);
// Save diff image for visual inspection
fs.writeFileSync(diffPath, PNG.sync.write(diff));
const totalPixels = width * height;
const changePercent = ((mismatchedPixels / totalPixels) * 100).toFixed(2);
return {
mismatchedPixels,
totalPixels,
changePercent: parseFloat(changePercent),
diffPath,
};
}
function resizeImage(img, targetWidth, targetHeight) {
const resized = new PNG({ width: targetWidth, height: targetHeight, fill: true });
PNG.bitblt(img, resized, 0, 0, img.width, img.height, 0, 0);
return resized;
}
module.exports = { compareScreenshots };
The threshold parameter is crucial. A value of 0.1 means two pixels are considered
"different" only if their color distance exceeds 10%. This accounts for minor rendering
differences across captures (sub-pixel anti-aliasing, font hinting) without missing real changes.
Step 3: Alerting on Changes
When the comparison detects meaningful changes (above a configured threshold), we send an alert. Here's a module that supports Slack webhooks:
// alert.js
async function sendSlackAlert(webhookUrl, { url, changePercent, diffPath }) {
const payload = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: 'šØ Website Change Detected',
},
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*URL:*\n${url}` },
{ type: 'mrkdwn', text: `*Change:*\n${changePercent}% pixels differ` },
{ type: 'mrkdwn', text: `*Time:*\n${new Date().toISOString()}` },
{ type: 'mrkdwn', text: `*Diff:*\n${diffPath}` },
],
},
],
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error('Slack alert failed:', response.status);
} else {
console.log('š Slack alert sent');
}
}
module.exports = { sendSlackAlert };
Step 4: Scheduling with Cron
Let's tie everything together with a main orchestrator that you can run on a schedule:
// index.js
const fs = require('fs');
const path = require('path');
const { captureScreenshot } = require('./capture');
const { compareScreenshots } = require('./compare');
const { sendSlackAlert } = require('./alert');
const config = require('./config.json');
async function checkUrl(entry) {
const slug = new URL(entry.url).hostname.replace(/\./g, '_');
const dir = path.join(__dirname, 'screenshots', slug);
const baselinePath = path.join(dir, 'baseline.png');
const latestPath = path.join(dir, 'latest.png');
const diffPath = path.join(dir, 'diff.png');
// Capture new screenshot
await captureScreenshot(entry.url, latestPath);
// First run ā no baseline yet, save as baseline
if (!fs.existsSync(baselinePath)) {
fs.copyFileSync(latestPath, baselinePath);
console.log(`š Baseline saved for ${entry.url}`);
return;
}
// Compare against baseline
const result = compareScreenshots(baselinePath, latestPath, diffPath);
console.log(`š ${entry.url}: ${result.changePercent}% changed`);
// Alert if change exceeds threshold
const threshold = entry.threshold || config.defaultThreshold || 0.5;
if (result.changePercent > threshold) {
console.log(`ā ļø Change exceeds threshold (${threshold}%)!`);
if (config.slackWebhook) {
await sendSlackAlert(config.slackWebhook, {
url: entry.url,
changePercent: result.changePercent,
diffPath,
});
}
// Update baseline to the new version
fs.copyFileSync(latestPath, baselinePath);
console.log(`š Baseline updated for ${entry.url}`);
}
}
async function main() {
console.log(`\nš Change detection run: ${new Date().toISOString()}\n`);
for (const entry of config.urls) {
try {
await checkUrl(entry);
} catch (error) {
console.error(`ā Error checking ${entry.url}:`, error.message);
}
}
}
main();
And the configuration file:
// config.json
{
"defaultThreshold": 0.5,
"slackWebhook": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"urls": [
{
"url": "https://your-site.com",
"threshold": 0.5
},
{
"url": "https://your-site.com/pricing",
"threshold": 0.1
},
{
"url": "https://competitor.com",
"threshold": 1.0
}
]
}
Schedule the check with crontab to run every hour:
# Run every hour at minute 0
0 * * * * cd /path/to/website-change-detector && node index.js >> /var/log/change-detector.log 2>&1
Advanced: Ignoring Dynamic Regions
Real-world pages often have dynamic content ā timestamps, ads, live counters, carousels ā that change on every load. These create false positives in your change detector. Here's how to handle them:
Option 1: CSS Injection via the API
Use SnapAPI's css parameter to hide dynamic elements before capture:
// In capture.js ā hide dynamic elements
const params = new URLSearchParams({
url: targetUrl,
api_key: API_KEY,
full_page: 'true',
format: 'png',
block_ads: 'true',
response: 'binary',
// Hide timestamps, ads, and live counters
css: `.timestamp, .ad-banner, .live-counter,
.cookie-banner, .chat-widget { display: none !important; }`,
});
Option 2: Region Masking in Comparison
Mask specific pixel regions during comparison so they're excluded from the diff calculation:
// Mask a rectangular region (x, y, width, height)
function maskRegion(imageData, x, y, width, height, imgWidth) {
for (let row = y; row < y + height; row++) {
for (let col = x; col < x + width; col++) {
const idx = (row * imgWidth + col) * 4;
// Set to a neutral gray so both images match
imageData[idx] = 128; // R
imageData[idx + 1] = 128; // G
imageData[idx + 2] = 128; // B
imageData[idx + 3] = 255; // A
}
}
}
// Usage: mask a header ad region before comparison
maskRegion(baseline.data, 0, 0, 1280, 90, 1280); // Top 90px
maskRegion(latest.data, 0, 0, 1280, 90, 1280);
Production-Ready Tips
Before running your change detector in production, consider these hardening strategies:
- Capture at consistent viewport sizes. Always lock your
widthandheightparameters. Responsive breakpoints can cause legitimate layout changes that appear as false positives. - Use
delaywisely. Set a 2-3 second delay for pages with animations or lazy-loaded content. This ensures the page is fully rendered before comparison. - Store historical diffs. Don't just keep the latest diff ā archive timestamped diffs so you can track when changes occurred over time.
- Set per-URL thresholds. Your homepage may tolerate 1% change (rotating banners), while your pricing page should alert at 0.1% change.
- Use
block_ads: true. Third-party ads are the #1 source of false positives in visual monitoring. Block them to focus on your actual content. - Add retry logic. Network timeouts or transient rendering issues can cause one-off failures. Retry the capture once before alerting.
Start Monitoring Visual Changes Today
100 free screenshots per month. Build your change detector in minutes with SnapAPI.
Get Started Free ā