← GrabShot Blog

How to Monitor Websites with Screenshot APIs: Detect Visual Changes Automatically

February 19, 2026 · 7 min read

Uptime monitoring tells you if a site is responding. But a 200 status code doesn't mean your homepage still looks right. A broken CSS deploy, a rogue ad injection, or a misplaced element can slip through health checks while your users see a mess.

Visual monitoring fills that gap. By capturing periodic screenshots of your pages and comparing them, you can catch layout breaks, defacements, and unexpected changes before your customers do.

In this guide, we'll build a practical website monitoring system using a screenshot API, a simple comparison algorithm, and a cron job. No Selenium clusters. No browser infrastructure to maintain.

Why Visual Monitoring Matters

Traditional monitoring checks status codes, response times, and maybe specific text on a page. Visual monitoring catches everything else:

If you run an e-commerce site, a SaaS dashboard, or any public-facing product, visual monitoring is insurance against silent failures.

The Architecture

Here's what we'll build:

  1. Scheduler runs every N minutes (cron or setInterval)
  2. Screenshot API captures the target URL as a PNG
  3. Comparison engine diffs the new screenshot against the last known good one
  4. Alert fires if the difference exceeds a threshold
  5. Storage keeps a rolling history of screenshots

The key insight: offload the browser rendering to an API. Running headless Chrome on your monitoring server is fragile, resource-heavy, and a maintenance headache. A screenshot API handles all that for you.

Step 1: Capture Screenshots on a Schedule

Let's start with a Node.js script that captures a screenshot every 15 minutes:

const fs = require('fs');
const path = require('path');

const API_KEY = process.env.GRABSHOT_API_KEY;
const TARGET_URL = 'https://example.com';
const SCREENSHOT_DIR = './screenshots';

async function captureScreenshot() {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const filename = `screenshot-${timestamp}.png`;

  const response = await fetch(
    `https://grabshot.dev/api/screenshot?` +
    `url=${encodeURIComponent(TARGET_URL)}` +
    `&width=1440&height=900&fullPage=false&format=png`,
    { headers: { 'X-API-Key': API_KEY } }
  );

  if (!response.ok) {
    console.error(`Capture failed: ${response.status}`);
    return null;
  }

  const buffer = Buffer.from(await response.arrayBuffer());
  const filepath = path.join(SCREENSHOT_DIR, filename);
  fs.writeFileSync(filepath, buffer);

  console.log(`Captured: ${filename} (${buffer.length} bytes)`);
  return filepath;
}

// Run it
captureScreenshot();

Same thing with curl, if you want to test from the command line:

curl -o screenshot.png \
  "https://grabshot.dev/api/screenshot?url=https://example.com&width=1440&height=900&format=png" \
  -H "X-API-Key: YOUR_API_KEY"

Step 2: Compare Screenshots with Pixel Diffing

Now we need to detect when something changes. The pixelmatch library is perfect for this. It compares two images pixel by pixel and returns the number of different pixels:

const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');

function compareScreenshots(imgPath1, imgPath2) {
  const img1 = PNG.sync.read(fs.readFileSync(imgPath1));
  const img2 = PNG.sync.read(fs.readFileSync(imgPath2));

  // Images must be the same dimensions
  if (img1.width !== img2.width || img1.height !== img2.height) {
    return { match: false, reason: 'dimension-mismatch' };
  }

  const diff = new PNG({ width: img1.width, height: img1.height });
  const numDiffPixels = pixelmatch(
    img1.data, img2.data, diff.data,
    img1.width, img1.height,
    { threshold: 0.1 } // color sensitivity
  );

  const totalPixels = img1.width * img1.height;
  const diffPercent = (numDiffPixels / totalPixels) * 100;

  // Save diff image for debugging
  if (diffPercent > 0.5) {
    fs.writeFileSync('diff-output.png', PNG.sync.write(diff));
  }

  return {
    match: diffPercent < 0.5, // less than 0.5% change = no alert
    diffPercent: diffPercent.toFixed(2),
    diffPixels: numDiffPixels
  };
}

The threshold parameter controls color sensitivity (0 = exact match, 1 = very lenient). A value of 0.1 works well for most sites. The 0.5% overall threshold avoids false positives from minor anti-aliasing differences or dynamic content like timestamps.

Step 3: Python Alternative

Prefer Python? Here's the equivalent using requests and Pillow:

import requests
import numpy as np
from PIL import Image
from io import BytesIO
from datetime import datetime

API_KEY = "your_api_key"

def capture(url):
    resp = requests.get(
        "https://grabshot.dev/api/screenshot",
        params={"url": url, "width": 1440, "height": 900, "format": "png"},
        headers={"X-API-Key": API_KEY}
    )
    resp.raise_for_status()
    return Image.open(BytesIO(resp.content))

def compare(img1, img2, threshold=0.5):
    """Returns True if images differ by more than threshold percent."""
    arr1 = np.array(img1)
    arr2 = np.array(img2)
    if arr1.shape != arr2.shape:
        return True  # dimensions changed = definite change
    diff = np.abs(arr1.astype(int) - arr2.astype(int))
    changed_pixels = np.sum(diff.max(axis=2) > 25)  # 25/255 tolerance
    pct = (changed_pixels / (arr1.shape[0] * arr1.shape[1])) * 100
    print(f"Difference: {pct:.2f}%")
    return pct > threshold

# Usage
baseline = capture("https://example.com")
# ... wait, then capture again ...
current = capture("https://example.com")
if compare(baseline, current):
    print("Visual change detected!")

Step 4: Wire Up Alerts

When a visual change exceeds your threshold, you need to know about it. Here's a simple Slack webhook integration:

async function sendAlert(url, diffPercent, diffImagePath) {
  const webhook = process.env.SLACK_WEBHOOK_URL;
  await fetch(webhook, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `Visual change detected on ${url}`,
      blocks: [{
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*Visual change detected*\n` +
                `URL: ${url}\n` +
                `Difference: ${diffPercent}%\n` +
                `Time: ${new Date().toISOString()}`
        }
      }]
    })
  });
}

You could also send email alerts, post to Discord, or trigger a PagerDuty incident for critical pages.

Step 5: Schedule with Cron

Save your monitoring script as monitor.js and add a crontab entry:

# Run every 15 minutes
*/15 * * * * cd /opt/website-monitor && node monitor.js >> monitor.log 2>&1

# Run every hour for less critical pages
0 * * * * cd /opt/website-monitor && node monitor.js --config hourly.json >> monitor.log 2>&1

For more robust scheduling, consider using CronPing to monitor that your monitoring script itself is running (yes, it's monitors all the way down).

Putting It All Together

Here's the complete monitoring loop:

const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');

const CONFIG = {
  apiKey: process.env.GRABSHOT_API_KEY,
  targets: [
    { url: 'https://yoursite.com', name: 'homepage' },
    { url: 'https://yoursite.com/pricing', name: 'pricing' },
    { url: 'https://yoursite.com/dashboard', name: 'dashboard' },
  ],
  threshold: 0.5,
  screenshotDir: './screenshots',
  baselineDir: './baselines',
};

async function monitor() {
  for (const target of CONFIG.targets) {
    console.log(`Checking ${target.name}...`);

    // Capture current state
    const currentPath = await captureScreenshot(target.url, target.name);
    if (!currentPath) continue;

    // Compare with baseline
    const baselinePath = path.join(
      CONFIG.baselineDir, `${target.name}-baseline.png`
    );

    if (!fs.existsSync(baselinePath)) {
      // First run: set baseline
      fs.copyFileSync(currentPath, baselinePath);
      console.log(`Baseline set for ${target.name}`);
      continue;
    }

    const result = compareScreenshots(baselinePath, currentPath);

    if (!result.match) {
      console.log(`CHANGE DETECTED on ${target.name}: ${result.diffPercent}%`);
      await sendAlert(target.url, result.diffPercent);
      // Update baseline after alert
      fs.copyFileSync(currentPath, baselinePath);
    } else {
      console.log(`${target.name}: OK (${result.diffPercent}% diff)`);
    }
  }
}

monitor().catch(console.error);

Tips for Production Use

Why Use an API Instead of Self-Hosted Puppeteer?

You could run headless Chrome yourself. But for monitoring specifically, an API approach wins:

GrabShot's API handles all the browser complexity. You send a URL, you get back a pixel-perfect screenshot. Your monitoring code stays simple and focused on the comparison logic.

Start Monitoring Your Sites

GrabShot's free plan includes 25 screenshots per month. Enough to monitor a few critical pages on a daily schedule.

Try the API Free →

Going Further

Once you have basic visual monitoring working, consider these extensions:

Visual monitoring isn't a replacement for your existing APM or uptime tools. It's the layer that catches what they miss: the things only human eyes would notice, automated at machine speed.