How to Build a Website Change Detection System with Screenshots
You need to know when a website changes. Maybe you are tracking a competitor's pricing page. Maybe you want to verify your own production site looks correct after a deploy. Or maybe a client pays you to monitor their homepage for unexpected layout shifts.
DOM-based diffing catches text changes, but it misses everything visual: broken images, CSS regressions, shifted layouts, font rendering issues. Screenshot comparison catches what the DOM cannot.
In this guide, you will build a complete website change detection system that captures screenshots on a schedule, compares them pixel-by-pixel, and sends you an alert when something changes. We will use a screenshot API so you do not have to run your own browser infrastructure.
How Visual Change Detection Works
The concept is straightforward:
- Capture a screenshot of the target URL
- Store it alongside the previous screenshot
- Compare the two images and calculate a difference score
- Alert if the difference exceeds your threshold
The tricky parts are handling dynamic content (ads, timestamps, cookie banners), choosing a good comparison algorithm, and running it reliably on a schedule. Let's solve each one.
Step 1: Capture Screenshots with an API
Instead of managing Puppeteer instances and Chrome processes, use a screenshot API. One HTTP request gives you a full-page render:
cURL
curl "https://grabshot.dev/v1/screenshot?url=https://example.com&width=1440&height=900&format=png" \
-H "Authorization: Bearer YOUR_API_KEY" \
-o screenshot-$(date +%Y%m%d-%H%M%S).png
Node.js
const fs = require('fs');
async function captureScreenshot(url, outputPath) {
const params = new URLSearchParams({
url,
width: '1440',
height: '900',
format: 'png',
full_page: 'false'
});
const res = await fetch(`https://grabshot.dev/v1/screenshot?${params}`, {
headers: { 'Authorization': 'Bearer YOUR_API_KEY' }
});
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(outputPath, buffer);
return outputPath;
}
// Capture and save with timestamp
const path = await captureScreenshot(
'https://example.com',
`./screenshots/capture-${Date.now()}.png`
);
Python
import requests
from datetime import datetime
def capture_screenshot(url: str, output_dir: str = "./screenshots") -> str:
response = requests.get(
"https://grabshot.dev/v1/screenshot",
params={
"url": url,
"width": 1440,
"height": 900,
"format": "png",
"full_page": "false"
},
headers={"Authorization": "Bearer YOUR_API_KEY"}
)
response.raise_for_status()
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
path = f"{output_dir}/capture-{timestamp}.png"
with open(path, "wb") as f:
f.write(response.content)
return path
A few tips for consistent captures:
- Use a fixed
widthandheightevery time. Viewport changes cause false positives. - Set
full_page=falsefor above-the-fold monitoring, ortrueif you need the entire page. - Add a
delayparameter (e.g.,delay=3000) for pages with heavy JavaScript rendering. - Use
block_ads=trueto remove ad-related visual noise.
Step 2: Compare Screenshots
Now you need to quantify the difference between two images. The most common approaches:
| Method | Speed | Accuracy | Best for |
|---|---|---|---|
| Pixel diff (RMSE) | Fast | High (sensitive) | Exact layout monitoring |
| Structural similarity (SSIM) | Medium | High (perceptual) | General change detection |
| Perceptual hash | Very fast | Low | Major redesign detection |
For most use cases, pixel diff with a threshold works well. Here is a Node.js implementation using pixelmatch:
Node.js: Pixel Comparison
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
const fs = require('fs');
function compareScreenshots(imgPath1, imgPath2, diffOutputPath) {
const img1 = PNG.sync.read(fs.readFileSync(imgPath1));
const img2 = PNG.sync.read(fs.readFileSync(imgPath2));
if (img1.width !== img2.width || img1.height !== img2.height) {
throw new Error('Image dimensions do not match');
}
const diff = new PNG({ width: img1.width, height: img1.height });
const mismatchedPixels = pixelmatch(
img1.data, img2.data, diff.data,
img1.width, img1.height,
{ threshold: 0.1 } // Color distance threshold
);
const totalPixels = img1.width * img1.height;
const changePercent = (mismatchedPixels / totalPixels) * 100;
// Save diff image (changed pixels highlighted in red)
fs.writeFileSync(diffOutputPath, PNG.sync.write(diff));
return { mismatchedPixels, totalPixels, changePercent };
}
const result = compareScreenshots(
'./screenshots/previous.png',
'./screenshots/current.png',
'./screenshots/diff.png'
);
console.log(`Change: ${result.changePercent.toFixed(2)}%`);
Python: Pixel Comparison
from PIL import Image
import numpy as np
def compare_screenshots(path1: str, path2: str) -> dict:
img1 = np.array(Image.open(path1))
img2 = np.array(Image.open(path2))
if img1.shape != img2.shape:
raise ValueError("Image dimensions do not match")
# Per-pixel difference across RGB channels
diff = np.abs(img1.astype(int) - img2.astype(int))
changed_pixels = np.any(diff > 25, axis=2).sum() # threshold per channel
total_pixels = img1.shape[0] * img1.shape[1]
change_percent = (changed_pixels / total_pixels) * 100
# Save diff visualization
diff_img = Image.fromarray(np.clip(diff * 3, 0, 255).astype(np.uint8))
diff_img.save("diff.png")
return {
"changed_pixels": int(changed_pixels),
"total_pixels": total_pixels,
"change_percent": round(change_percent, 2)
}
Step 3: Handle Dynamic Content
Real websites have elements that change every page load: timestamps, ads, cookie banners, live counters. These cause false positives. Strategies to deal with them:
- CSS injection: Use the screenshot API's
cssparameter to hide volatile elements:css=.cookie-banner,.ad-slot,.live-counter{display:none!important} - Region masking: Only compare specific areas of the page (header, hero section, pricing table) by cropping before comparison.
- Threshold tuning: A 0.5% to 2% change threshold ignores minor rendering differences while catching real changes.
- Multiple captures: Take two screenshots 5 seconds apart. If they differ from each other but not from the baseline, it is dynamic content.
# Hide cookie banners and ads during capture
curl "https://grabshot.dev/v1/screenshot?url=https://example.com\
&css=.cookie-banner,.ad-slot{display:none!important}\
&block_ads=true" \
-H "Authorization: Bearer YOUR_API_KEY" -o clean-capture.png
Step 4: Set Up Scheduled Monitoring
Wrap everything into a monitoring script that runs on a schedule. Here is a complete Node.js example:
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
const CONFIG = {
apiKey: process.env.GRABSHOT_API_KEY,
urls: [
{ url: 'https://competitor.com/pricing', name: 'competitor-pricing', threshold: 1.0 },
{ url: 'https://mysite.com', name: 'homepage', threshold: 0.5 },
],
screenshotDir: './monitoring/screenshots',
webhookUrl: process.env.SLACK_WEBHOOK_URL,
};
async function captureAndCompare(target) {
const { url, name, threshold } = target;
const dir = path.join(CONFIG.screenshotDir, name);
fs.mkdirSync(dir, { recursive: true });
// Capture new screenshot
const params = new URLSearchParams({
url, width: '1440', height: '900',
format: 'png', block_ads: 'true'
});
const res = await fetch(`https://grabshot.dev/v1/screenshot?${params}`, {
headers: { 'Authorization': `Bearer ${CONFIG.apiKey}` }
});
const buffer = Buffer.from(await res.arrayBuffer());
const currentPath = path.join(dir, 'current.png');
const previousPath = path.join(dir, 'previous.png');
const diffPath = path.join(dir, 'diff.png');
// First run: save baseline and exit
if (!fs.existsSync(currentPath)) {
fs.writeFileSync(currentPath, buffer);
console.log(`[${name}] Baseline saved`);
return null;
}
// Rotate: current becomes previous
fs.copyFileSync(currentPath, previousPath);
fs.writeFileSync(currentPath, buffer);
// Compare
const img1 = PNG.sync.read(fs.readFileSync(previousPath));
const img2 = PNG.sync.read(fs.readFileSync(currentPath));
const diff = new PNG({ width: img1.width, height: img1.height });
const mismatched = pixelmatch(
img1.data, img2.data, diff.data,
img1.width, img1.height, { threshold: 0.1 }
);
const changePercent = (mismatched / (img1.width * img1.height)) * 100;
if (changePercent > threshold) {
fs.writeFileSync(diffPath, PNG.sync.write(diff));
console.log(`[${name}] CHANGE DETECTED: ${changePercent.toFixed(2)}%`);
await sendAlert(name, url, changePercent);
} else {
console.log(`[${name}] No significant change (${changePercent.toFixed(2)}%)`);
}
return { name, changePercent };
}
async function sendAlert(name, url, changePercent) {
if (!CONFIG.webhookUrl) return;
await fetch(CONFIG.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Visual change detected on *${name}*\nURL: ${url}\nChange: ${changePercent.toFixed(2)}%`
})
});
}
// Run all checks
async function main() {
for (const target of CONFIG.urls) {
await captureAndCompare(target);
}
}
main().catch(console.error);
Schedule it with cron (every hour):
0 * * * * cd /home/app/monitor && node monitor.js >> monitor.log 2>&1
Step 5: Build a Change History
Instead of just keeping current and previous, archive every capture with timestamps. This gives you a visual changelog you can scrub through:
// Archive each capture instead of overwriting
const archivePath = path.join(dir, `capture-${Date.now()}.png`);
fs.writeFileSync(archivePath, buffer);
// Clean up old archives (keep last 30 days)
const files = fs.readdirSync(dir).filter(f => f.startsWith('capture-'));
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
for (const file of files) {
const ts = parseInt(file.split('-')[1]);
if (ts < thirtyDaysAgo) fs.unlinkSync(path.join(dir, file));
}
This is invaluable for answering "when did the pricing page change?" or proving to a client that their site broke on a specific date.
Real-World Use Cases
- Competitor monitoring: Track pricing pages, feature lists, and landing pages. Know when a competitor launches something new before their announcement.
- Production smoke tests: After every deploy, screenshot key pages and compare against the pre-deploy baseline. Catch CSS regressions before users do.
- Compliance: Regulated industries need proof that website content matches approved versions. Automated screenshots provide timestamped evidence.
- Client reporting: Web agencies can show clients exactly what changed and when, backed by visual diffs.
- Brand monitoring: Detect unauthorized changes to partner pages displaying your brand, logos, or product information.
Start Capturing Screenshots
GrabShot's screenshot API handles the browser rendering so you can focus on building your monitoring logic. 25 free screenshots per month.
Try It FreePerformance Tips
- PNG for comparison, JPEG for archives. PNG is lossless (no compression artifacts affecting diff), but JPEG saves storage for historical records.
- Resize before comparing. Comparing 1440x900 images is fast. Comparing 1440x8000 full-page captures is slow. If you only care about above-the-fold, set
full_page=false. - Batch your API calls. If you monitor 50 URLs, do not fire all requests at once. Stagger them to stay within rate limits and avoid timeouts.
- Store diff images only when changes are detected. No point saving a diff that shows nothing.
Wrapping Up
Visual change detection is one of those problems that sounds complex but breaks down into simple steps: capture, compare, alert. Using a website screenshot API eliminates the hardest part (browser infrastructure) and lets you focus on the comparison logic and alerting that matters to your workflow.
The complete Node.js example above is production-ready. Clone it, swap in your API key and URLs, set up a cron job, and you have a monitoring system running in under an hour.
Related reads: