arrow_back Back to blog
Tutorials March 8, 2026 9 min read

Building a Website Monitoring Tool with len.sh

Building a Website Monitoring Tool with len.sh

Uptime monitors tell you when a site goes down. But what about when the homepage silently breaks — a key button disappears, a hero image fails to load, or a CSS deploy wrecks your layout? Uptime is green, but your users are seeing a broken experience.

Visual monitoring catches what uptime monitors miss. In this tutorial, you’ll build a Node.js tool that periodically captures screenshots of your websites using the len.sh screenshot API, compares them to baseline images pixel-by-pixel, and sends a Slack alert the moment something looks wrong.

By the end, you’ll have a lightweight, self-hosted monitoring tool you can run on a server or in CI/CD with just a few hundred lines of JavaScript.

What We’re Building

  • A scheduled job that screenshots one or more URLs every 30 minutes
  • Baseline management: first run saves a baseline, subsequent runs compare against it
  • Pixel-level diff detection using pixelmatch
  • Slack webhook alerts with the diff percentage and which site changed
  • A configurable threshold so you can tune sensitivity

Prerequisites

  • Node.js 18+ (for native fetch and top-level await)
  • A len.sh API key — the free tier is plenty for monitoring a handful of sites. Sign up here
  • A Slack Incoming Webhook URL (optional, but recommended for alerts)

Project Setup

Create a new directory and initialize the project:

mkdir website-monitor
cd website-monitor
npm init -y

Install the dependencies:

npm install node-cron pixelmatch pngjs sharp
  • node-cron — schedule the monitoring job
  • pixelmatch — pixel-by-pixel image comparison
  • pngjs — read/write PNG files for diffing
  • sharp — resize and convert images to a consistent format before comparing

Update package.json to use ES modules:

{
  "name": "website-monitor",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "node-cron": "^3.0.3",
    "pixelmatch": "^6.0.0",
    "pngjs": "^7.0.0",
    "sharp": "^0.33.0"
  }
}

Create the directory structure:

mkdir -p screenshots/baseline screenshots/current screenshots/diff

Step 1: Capturing Screenshots

The len.sh API makes capturing screenshots dead simple. You call a single endpoint with your URL and API key, and get back a PNG, JPEG, WebP, or PDF.

Create screenshot.js:

// screenshot.js
import { writeFile } from 'fs/promises';
import path from 'path';

const API_BASE = 'https://api.len.sh/v1/screenshot';

/**
 * Capture a screenshot of a URL using the len.sh API.
 * @param {string} url - The page URL to screenshot
 * @param {string} outputPath - Where to save the image file
 * @param {object} options - Optional API parameters
 */
export async function captureScreenshot(url, outputPath, options = {}) {
  const params = new URLSearchParams({
    url,
    api_key: process.env.LENSH_API_KEY,
    width: options.width ?? 1280,
    height: options.height ?? 800,
    format: options.format ?? 'png',
    full_page: options.fullPage ?? false,
    delay: options.delay ?? 500,
  });

  const response = await fetch(`${API_BASE}?${params}`);

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Screenshot failed for ${url}: ${response.status} ${error}`);
  }

  const buffer = await response.arrayBuffer();
  await writeFile(outputPath, Buffer.from(buffer));

  console.log(`Screenshot saved: ${outputPath}`);
  return outputPath;
}

A few notes on the parameters:

  • width / height — viewport dimensions in pixels. Use your most common breakpoint (1280×800 is a safe default)
  • formatpng is best for diffing since it’s lossless; jpeg and webp work for archiving
  • full_page — set to true to capture the entire scrollable page, not just the visible viewport
  • delay — milliseconds to wait after page load before snapping the screenshot. Useful for pages that animate in or load data asynchronously

Step 2: Storing Baselines

The first time the monitor runs, it saves a screenshot as the baseline. Every subsequent run saves to current/, then compares against the baseline.

Create storage.js:

// storage.js
import { access, copyFile, mkdir } from 'fs/promises';
import path from 'path';

const DIRS = {
  baseline: 'screenshots/baseline',
  current: 'screenshots/current',
  diff: 'screenshots/diff',
};

/**
 * Convert a URL to a safe filename.
 * e.g. "https://example.com/pricing" -> "example.com-pricing.png"
 */
export function urlToFilename(url) {
  const parsed = new URL(url);
  const slug = (parsed.hostname + parsed.pathname)
    .replace(/[^a-z0-9]/gi, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '');
  return `${slug}.png`;
}

/**
 * Returns paths for baseline, current, and diff images.
 */
export function getPaths(url) {
  const filename = urlToFilename(url);
  return {
    baseline: path.join(DIRS.baseline, filename),
    current: path.join(DIRS.current, filename),
    diff: path.join(DIRS.diff, filename),
  };
}

/**
 * Check if a baseline image exists for this URL.
 */
export async function hasBaseline(url) {
  try {
    await access(getPaths(url).baseline);
    return true;
  } catch {
    return false;
  }
}

/**
 * Promote the current screenshot to the baseline.
 */
export async function promoteToBaseline(url) {
  const paths = getPaths(url);
  await copyFile(paths.current, paths.baseline);
  console.log(`Baseline saved for ${url}`);
}

Step 3: Comparing Screenshots

With two screenshots in hand, use pixelmatch to compare them pixel-by-pixel and generate a highlighted diff image.

Create compare.js:

// compare.js
import { readFile, writeFile } from 'fs/promises';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import sharp from 'sharp';

/**
 * Normalise an image to a fixed size PNG buffer so pixelmatch
 * can compare them even if they have slightly different dimensions.
 */
async function normalise(filePath, width = 1280, height = 800) {
  return sharp(filePath)
    .resize(width, height, { fit: 'cover' })
    .png()
    .toBuffer();
}

/**
 * Compare two screenshots and write a diff image.
 * Returns the percentage of pixels that differ.
 *
 * @param {string} baselinePath
 * @param {string} currentPath
 * @param {string} diffPath
 * @returns {number} diffPercent — 0 means identical, 1 means completely different
 */
export async function compareScreenshots(baselinePath, currentPath, diffPath) {
  const WIDTH = 1280;
  const HEIGHT = 800;

  const [baselineBuffer, currentBuffer] = await Promise.all([
    normalise(baselinePath, WIDTH, HEIGHT),
    normalise(currentPath, WIDTH, HEIGHT),
  ]);

  const baseline = PNG.sync.read(baselineBuffer);
  const current = PNG.sync.read(currentBuffer);
  const diff = new PNG({ width: WIDTH, height: HEIGHT });

  const mismatchedPixels = pixelmatch(
    baseline.data,
    current.data,
    diff.data,
    WIDTH,
    HEIGHT,
    { threshold: 0.1 } // per-pixel colour tolerance
  );

  const diffPercent = mismatchedPixels / (WIDTH * HEIGHT);

  await writeFile(diffPath, PNG.sync.write(diff));

  return diffPercent;
}

The threshold option in pixelmatch controls per-pixel colour tolerance (0 = exact match required, 1 = anything goes). 0.1 is a good default — it ignores minor anti-aliasing differences while catching real layout changes.

Step 4: Sending Slack Alerts

Create notify.js to post a message to Slack via an Incoming Webhook:

// notify.js

/**
 * Send a Slack alert when a visual change is detected.
 *
 * @param {string} url - The monitored URL
 * @param {number} diffPercent - Fraction of pixels that changed (0–1)
 */
export async function sendSlackAlert(url, diffPercent) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;
  if (!webhookUrl) {
    console.warn('SLACK_WEBHOOK_URL not set — skipping alert');
    return;
  }

  const percentage = (diffPercent * 100).toFixed(2);

  const payload = {
    text: `:warning: Visual change detected on *${url}*`,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `:warning: *Visual change detected*\n*URL:* ${url}\n*Diff:* ${percentage}% of pixels changed`,
        },
      },
      {
        type: 'context',
        elements: [
          {
            type: 'mrkdwn',
            text: `Detected at ${new Date().toUTCString()}`,
          },
        ],
      },
    ],
  };

  const response = await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    console.error(`Slack notification failed: ${response.status}`);
  } else {
    console.log(`Slack alert sent for ${url} (${percentage}% diff)`);
  }
}

Step 5: Scheduling with Cron

Now tie everything together with a scheduler. Create monitor.js:

// monitor.js
import { captureScreenshot } from './screenshot.js';
import { getPaths, hasBaseline, promoteToBaseline } from './storage.js';
import { compareScreenshots } from './compare.js';
import { sendSlackAlert } from './notify.js';

// Threshold: alert if more than 1% of pixels changed
const DIFF_THRESHOLD = 0.01;

/**
 * Run a single monitoring check for one URL.
 */
export async function checkSite(url) {
  console.log(`Checking ${url}...`);
  const paths = getPaths(url);

  // 1. Capture the current screenshot
  await captureScreenshot(url, paths.current);

  // 2. If no baseline exists yet, save this as the baseline and exit
  if (!(await hasBaseline(url))) {
    console.log(`No baseline found for ${url}. Saving as baseline.`);
    await promoteToBaseline(url);
    return;
  }

  // 3. Compare current against baseline
  const diffPercent = await compareScreenshots(
    paths.baseline,
    paths.current,
    paths.diff
  );

  const pct = (diffPercent * 100).toFixed(2);
  console.log(`${url} — diff: ${pct}%`);

  // 4. Alert if above threshold
  if (diffPercent > DIFF_THRESHOLD) {
    console.log(`Change detected on ${url}! Sending alert...`);
    await sendSlackAlert(url, diffPercent);
  } else {
    console.log(`${url} looks good.`);
  }
}

Putting It All Together

Create the main entry point index.js:

// index.js
import cron from 'node-cron';
import { checkSite } from './monitor.js';

// --- Configuration ---
const CONFIG = {
  // Sites to monitor
  sites: [
    'https://example.com',
    'https://example.com/pricing',
    'https://example.com/login',
  ],

  // Cron expression — every 30 minutes
  // Format: minute hour day month weekday
  schedule: '*/30 * * * *',
};

// Run all site checks in parallel
async function runAllChecks() {
  console.log(`\n[${new Date().toISOString()}] Starting monitoring run...`);
  await Promise.allSettled(CONFIG.sites.map(checkSite));
  console.log('Monitoring run complete.\n');
}

// Run immediately on start
await runAllChecks();

// Then run on the configured schedule
cron.schedule(CONFIG.schedule, runAllChecks);

console.log(`Monitoring ${CONFIG.sites.length} site(s) every 30 minutes.`);
console.log('Press Ctrl+C to stop.');

Set your environment variables and run:

export LENSH_API_KEY=your_api_key_here
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url

node index.js

On the first run, you’ll see baseline images saved to screenshots/baseline/. On subsequent runs, the tool compares against those baselines and alerts on any significant change.

Complete File Structure

website-monitor/
├── index.js          # Entry point + cron scheduler
├── monitor.js        # Core check logic
├── screenshot.js     # len.sh API wrapper
├── storage.js        # Baseline file management
├── compare.js        # Pixel diff with pixelmatch
├── notify.js         # Slack webhook alert
├── package.json
└── screenshots/
    ├── baseline/     # Reference images
    ├── current/      # Latest captures
    └── diff/         # Highlighted diff images

Enhancements to Consider

The tool above is functional and production-ready for small monitoring needs. Here are directions to take it further:

Multiple viewport sizes — Call captureScreenshot with different width values to monitor both desktop and mobile layouts. The len.sh API supports any viewport size, so you can catch mobile-only breakages.

Email alerts — Add a Nodemailer or SendGrid step alongside the Slack notification for higher-visibility alerts.

History and trending — Instead of overwriting current/ each run, timestamp the filenames. Over time you’ll have a visual history of your site that makes it easy to pinpoint exactly when a change occurred.

CI/CD integration — Run the monitor as part of your deployment pipeline. Screenshot before and after deploy, diff them, and fail the pipeline if the diff exceeds your threshold. This catches regressions before they reach production.

Dashboard UI — Serve the screenshots/ directory with a simple Express or Astro app so your team can browse baselines, current captures, and diffs without digging through the filesystem.

Updating baselines — Add a CLI flag (node index.js --update-baseline https://example.com) to promote the current screenshot to the new baseline after intentional UI changes.

Conclusion

In under 200 lines of JavaScript, you’ve built a visual monitoring tool that catches the kind of subtle, silent regressions that uptime monitors completely miss. The len.sh screenshot API does the heavy lifting of rendering pages in a real browser — you just call an endpoint and get back an image.

The key pieces are:

  1. len.sh API for reliable, headless screenshots without managing browser infrastructure
  2. pixelmatch for fast, accurate pixel-level comparison
  3. node-cron for scheduling
  4. Slack webhooks for instant alerts

Ready to start monitoring? Get your free len.sh API key and have your first baseline running in minutes.

Try len.sh for free

Start capturing screenshots with a simple API call. No credit card required.