← GrabShot Blog

How to Build a Website Change Detection System with Screenshots

February 19, 2026 · 10 min read

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:

  1. Capture a screenshot of the target URL
  2. Store it alongside the previous screenshot
  3. Compare the two images and calculate a difference score
  4. 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:

Step 2: Compare Screenshots

Now you need to quantify the difference between two images. The most common approaches:

MethodSpeedAccuracyBest for
Pixel diff (RMSE)FastHigh (sensitive)Exact layout monitoring
Structural similarity (SSIM)MediumHigh (perceptual)General change detection
Perceptual hashVery fastLowMajor 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:

# 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

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 Free

Performance Tips

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: