How to Build a Website Change Detector with Screenshots

Automate visual change detection for any website. Capture periodic screenshots, compare them pixel-by-pixel, and get alerted the moment something changes.

šŸ“‹ 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:

Architecture Overview

Our change detector has four components:

  1. Screenshot Capture — Takes a screenshot of the target URL using a Screenshot API
  2. Image Storage — Saves screenshots locally with timestamps for comparison and audit trails
  3. Pixel Comparison — Compares the new screenshot against the previous baseline using pixelmatch
  4. 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:

  1. Capture at consistent viewport sizes. Always lock your width and height parameters. Responsive breakpoints can cause legitimate layout changes that appear as false positives.
  2. Use delay wisely. Set a 2-3 second delay for pages with animations or lazy-loaded content. This ensures the page is fully rendered before comparison.
  3. Store historical diffs. Don't just keep the latest diff — archive timestamped diffs so you can track when changes occurred over time.
  4. Set per-URL thresholds. Your homepage may tolerate 1% change (rotating banners), while your pricing page should alert at 0.1% change.
  5. 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.
  6. 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 →