How to Compare Website Screenshots Programmatically

Published February 27, 2026 · 10 min read

You push a CSS change and everything looks fine on your screen. Two hours later, a customer reports that the checkout button disappeared on mobile. Sound familiar? Screenshot comparison catches these problems before your users do.

In this guide, you'll learn how to capture website screenshots with an API, compare them programmatically, and build an automated visual diff pipeline. We'll cover three approaches: pixel-level diffing, structural similarity (SSIM), and perceptual hashing, with working code in Node.js and Python.

Why Compare Screenshots?

Manual visual QA doesn't scale. When your site has 50 pages across 4 breakpoints, that's 200 visual checks per deployment. Screenshot comparison automates this by:

Step 1: Capture Screenshots with an API

Before you can compare, you need consistent screenshots. Running your own Puppeteer instance works, but you'll spend more time managing Chrome crashes, memory leaks, and font rendering than doing actual comparison work. A screenshot API handles the rendering so you can focus on the diffing.

Capture with curl

# Capture baseline screenshot
curl "https://grabshot.dev/api/screenshot?url=https://example.com&width=1440&height=900&format=png" \
  -H "x-api-key: YOUR_API_KEY" \
  -o baseline.png

# Later, capture the current version
curl "https://grabshot.dev/api/screenshot?url=https://example.com&width=1440&height=900&format=png" \
  -H "x-api-key: YOUR_API_KEY" \
  -o current.png

Important: use format=png for comparison work. JPEG compression introduces artifacts that create false positives in pixel-level diffs. Also keep width and height consistent between captures, or your comparison will just be measuring layout reflow.

Capture with Node.js

const fs = require('fs');

async function captureScreenshot(url, filename) {
  const params = new URLSearchParams({
    url,
    width: '1440',
    height: '900',
    format: 'png',
    full_page: 'false'
  });

  const res = await fetch(`https://grabshot.dev/api/screenshot?${params}`, {
    headers: { 'x-api-key': process.env.GRABSHOT_API_KEY }
  });

  if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
  const buffer = Buffer.from(await res.arrayBuffer());
  fs.writeFileSync(filename, buffer);
  return filename;
}

// Capture baseline and current
await captureScreenshot('https://example.com', 'baseline.png');
// ... deploy changes ...
await captureScreenshot('https://example.com', 'current.png');

Step 2: Pixel-Level Diff with Pixelmatch

The simplest comparison approach: check every pixel. Pixelmatch is a fast, dependency-free library that works well for this.

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

function compareScreenshots(baselinePath, currentPath, diffPath) {
  const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
  const current = PNG.sync.read(fs.readFileSync(currentPath));

  // Images must be the same dimensions
  if (baseline.width !== current.width || baseline.height !== current.height) {
    throw new Error('Image dimensions differ — ensure consistent capture settings');
  }

  const { width, height } = baseline;
  const diff = new PNG({ width, height });

  const mismatchedPixels = pixelmatch(
    baseline.data, current.data, diff.data,
    width, height,
    { threshold: 0.1 } // Color distance threshold (0 = exact, 1 = lenient)
  );

  // Write the diff image (changed pixels highlighted in red)
  fs.writeFileSync(diffPath, PNG.sync.write(diff));

  const totalPixels = width * height;
  const diffPercentage = ((mismatchedPixels / totalPixels) * 100).toFixed(2);

  return {
    mismatchedPixels,
    totalPixels,
    diffPercentage: parseFloat(diffPercentage),
    hasDifference: diffPercentage > 0.5 // Allow 0.5% tolerance for anti-aliasing
  };
}

const result = compareScreenshots('baseline.png', 'current.png', 'diff.png');
console.log(`Diff: ${result.diffPercentage}% pixels changed`);

if (result.hasDifference) {
  console.log('Visual change detected! Check diff.png for details.');
}

The threshold parameter controls sensitivity. Set it to 0.1 for most use cases. Lower values catch subtle color shifts but increase false positives from font anti-aliasing differences.

Step 3: Structural Similarity (SSIM) with Python

Pixel-level diffs are noisy. A one-pixel shift in a dropdown menu lights up the entire area below it. SSIM (Structural Similarity Index) is smarter: it compares luminance, contrast, and structure, giving you a single score from 0 (completely different) to 1 (identical).

import requests
from PIL import Image
from skimage.metrics import structural_similarity as ssim
import numpy as np
import os

def capture(url, filename):
    """Capture a screenshot via GrabShot API."""
    response = requests.get(
        'https://grabshot.dev/api/screenshot',
        params={'url': url, 'width': 1440, 'height': 900, 'format': 'png'},
        headers={'x-api-key': os.environ['GRABSHOT_API_KEY']}
    )
    response.raise_for_status()
    with open(filename, 'wb') as f:
        f.write(response.content)
    return filename

def compare_ssim(baseline_path, current_path):
    """Compare two screenshots using SSIM."""
    baseline = np.array(Image.open(baseline_path).convert('L'))  # Convert to grayscale
    current = np.array(Image.open(current_path).convert('L'))

    score, diff_image = ssim(baseline, current, full=True)
    return {
        'score': round(score, 4),
        'is_similar': score > 0.95,  # 95% similarity threshold
        'diff_image': diff_image
    }

# Usage
capture('https://example.com', 'baseline.png')
# ... time passes or changes deploy ...
capture('https://example.com', 'current.png')

result = compare_ssim('baseline.png', 'current.png')
print(f"SSIM Score: {result['score']}")
print(f"Pages are {'similar' if result['is_similar'] else 'different'}")

Install dependencies with: pip install requests Pillow scikit-image numpy

Step 4: Build a Multi-Page Comparison Pipeline

Real-world use means comparing multiple pages across multiple viewports. Here's a practical Node.js pipeline that captures and compares a list of URLs:

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

const PAGES = [
  { name: 'homepage', url: 'https://yoursite.com/' },
  { name: 'pricing', url: 'https://yoursite.com/pricing' },
  { name: 'docs', url: 'https://yoursite.com/docs' },
  { name: 'login', url: 'https://yoursite.com/login' },
];

const VIEWPORTS = [
  { name: 'desktop', width: 1440, height: 900 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'mobile', width: 375, height: 812 },
];

async function captureAll(label) {
  const dir = `screenshots/${label}`;
  fs.mkdirSync(dir, { recursive: true });

  for (const page of PAGES) {
    for (const vp of VIEWPORTS) {
      const filename = `${dir}/${page.name}-${vp.name}.png`;
      const params = new URLSearchParams({
        url: page.url,
        width: String(vp.width),
        height: String(vp.height),
        format: 'png'
      });

      const res = await fetch(`https://grabshot.dev/api/screenshot?${params}`, {
        headers: { 'x-api-key': process.env.GRABSHOT_API_KEY }
      });

      if (res.ok) {
        fs.writeFileSync(filename, Buffer.from(await res.arrayBuffer()));
        console.log(`Captured: ${filename}`);
      }

      // Respect rate limits
      await new Promise(r => setTimeout(r, 500));
    }
  }
}

async function compareAll() {
  const results = [];

  for (const page of PAGES) {
    for (const vp of VIEWPORTS) {
      const baseFile = `screenshots/baseline/${page.name}-${vp.name}.png`;
      const currFile = `screenshots/current/${page.name}-${vp.name}.png`;

      if (!fs.existsSync(baseFile) || !fs.existsSync(currFile)) continue;

      const baseline = PNG.sync.read(fs.readFileSync(baseFile));
      const current = PNG.sync.read(fs.readFileSync(currFile));

      if (baseline.width !== current.width) continue;

      const diff = new PNG({ width: baseline.width, height: baseline.height });
      const mismatched = pixelmatch(
        baseline.data, current.data, diff.data,
        baseline.width, baseline.height,
        { threshold: 0.1 }
      );

      const pct = ((mismatched / (baseline.width * baseline.height)) * 100).toFixed(2);
      results.push({
        page: page.name,
        viewport: vp.name,
        diffPercent: parseFloat(pct),
        changed: pct > 0.5
      });
    }
  }

  return results;
}

// Run the pipeline
await captureAll('baseline');
// ... deploy changes ...
await captureAll('current');
const report = await compareAll();

// Print summary
console.log('\n--- Visual Diff Report ---');
for (const r of report) {
  const status = r.changed ? '⚠️  CHANGED' : '✅ OK';
  console.log(`${status} ${r.page} (${r.viewport}): ${r.diffPercent}%`);
}

Consistent Screenshots for Reliable Comparisons

GrabShot renders pages in a real Chrome browser with consistent fonts, viewport sizing, and network conditions, so your diffs show real changes, not rendering noise.

Try It Free

Handling Common Pitfalls

Screenshot comparison sounds simple until you hit these edge cases:

ProblemCauseSolution
False positives everywhereAnti-aliasing differences, font renderingUse threshold 0.1-0.2 in pixelmatch, or switch to SSIM
Dynamic content (ads, timestamps)Content changes between capturesMask known dynamic regions before comparison
Animations caught mid-frameCSS animations or carouselsAdd delay=2000 to API call, or disable animations via injected CSS
Cookie bannersFirst visit vs. returning visitorInject a cookie acceptance script or use css=.cookie-banner{display:none}
Lazy-loaded images not appearingImages load on scrollUse full_page=true or add a scroll delay

Masking Dynamic Regions

If your page has a clock, live chat widget, or ad slot, you'll get false diffs every time. Mask those regions before comparing:

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

function maskRegion(img, x, y, w, h) {
  // Fill region with solid color so it's identical in both images
  for (let py = y; py < y + h && py < img.height; py++) {
    for (let px = x; px < x + w && px < img.width; px++) {
      const idx = (img.width * py + px) << 2;
      img.data[idx] = 128;     // R
      img.data[idx + 1] = 128; // G
      img.data[idx + 2] = 128; // B
      img.data[idx + 3] = 255; // A
    }
  }
}

// Mask a chat widget in the bottom-right corner
const img = PNG.sync.read(fs.readFileSync('screenshot.png'));
maskRegion(img, img.width - 400, img.height - 600, 400, 600);
fs.writeFileSync('screenshot-masked.png', PNG.sync.write(img));

Integrating with CI/CD

The real power of screenshot comparison is running it automatically on every pull request. Here's a GitHub Actions workflow:

name: Visual Regression Test
on: [pull_request]

jobs:
  visual-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm install pngjs pixelmatch

      - name: Capture current screenshots
        run: node capture.js current
        env:
          GRABSHOT_API_KEY: ${{ secrets.GRABSHOT_API_KEY }}

      - name: Download baseline screenshots
        uses: actions/download-artifact@v4
        with:
          name: visual-baselines
          path: screenshots/baseline

      - name: Compare screenshots
        run: node compare.js

      - name: Upload diff images
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: screenshots/diffs/

This runs on every PR, compares against stored baselines, and uploads diff images as artifacts when changes are detected. Your team can review the visual diffs alongside the code changes.

Which Comparison Method Should You Use?

MethodBest ForSpeedAccuracy
Pixel diff (pixelmatch)Exact change detection, CI/CD gatesFastHigh (but noisy)
SSIMOverall similarity score, tolerant of minor shiftsMediumHigh
Perceptual hashQuick "same or different" check, large batchesVery fastLow (coarse)

For most teams, start with pixelmatch in CI/CD for precision, and use SSIM for monitoring dashboards where you want a clean pass/fail signal without investigating every anti-aliasing diff.

Next Steps

Screenshot comparison is one piece of a larger visual testing strategy. Once you have the basics working: