Website Monitoring with Automated Screenshots

Rendex Team··7 min read
monitoringtutorialautomation
TypeScript Cloudflare Worker code for website monitoring screenshots: cron trigger calling Rendex API to capture pages hourly and diff against baseline

HTTP monitoring tells you whether your server sent bytes. It does not tell you whether your page looks correct. A 200 status can accompany a white screen, a broken layout, or missing images. Your uptime dashboard will stay green while your users see nothing.

Screenshot monitoring catches what HTTP pings cannot. You capture what the page actually rendered, store it, and compare it against a known-good baseline. When the visual delta crosses a threshold, you get an alert.

This guide builds a Cloudflare Workers cron that runs every hour, captures screenshots of your key pages using the Rendex API, and sends a webhook alert when something changes.

Prefer not to run your own infra?Everything below is a fully valid DIY setup — but if you'd rather skip the cron, the KV baselines, and the diff code, Rendex Watch does this for you: it re-captures each page on a schedule in real Chrome, compares every check against the baseline, and shows the change as a highlighted before/after overlay — alerting you by email (every plan) or a signed webhook (Starter+). It runs on the same API key and credit pool as the screenshot API below, so you can start from the Watch docs instead of building the worker.

Why Visual Monitoring Catches More

These failures all return 200 but break the user experience:

  • JS runtime errors: React throws, the page renders blank, your error boundary shows "Something went wrong."
  • CSS deploy problems: A class name gets hashed differently, your hero section collapses to zero height.
  • Third-party failures: A CDN-hosted font fails, text falls back to system sans-serif throughout your site.
  • CMS content mistakes: A published draft overwrites your pricing page with placeholder copy.
  • Image CDN issues: Your product images 404 but the page structure is intact.

HTTP monitoring misses all of these. Website monitoring screenshots catch them within the hour.

Architecture

The setup has three components:

  1. Cloudflare Workers cron: Runs on a schedule (every hour by default). Calls the Rendex API for each monitored URL.
  2. Baseline storage: Cloudflare KV stores the last known-good screenshot as an ArrayBuffer keyed by URL.
  3. Alert delivery: When the visual diff exceeds your threshold, the worker fires a webhook to Slack, PagerDuty, or any endpoint you control.

The Rendex API handles the browser rendering on Cloudflare's edge network. You do not install Chromium, manage memory, or deal with cold starts.

Step 1: Configure the Worker

Create a new Cloudflare Worker project and add a scheduled trigger towrangler.toml:

wrangler.toml
name = "site-monitor"
main = "src/index.ts"
compatibility_date = "2025-01-01"

[triggers]
crons = ["0 * * * *"]  # fire every hour

[vars]
# Set sensitive values with: wrangler secret put RENDEX_API_KEY
MONITORED_URLS = '["https://yoursite.com","https://yoursite.com/pricing","https://yoursite.com/docs"]'

[[kv_namespaces]]
binding = "SNAPSHOTS"
id = "YOUR_KV_NAMESPACE_ID"

Store your API key and webhook URL as Wrangler secrets so they are not committed to source control:

setup.sh
# Get your Rendex API key at rendex.dev/login
wrangler secret put RENDEX_API_KEY
# Paste: rdx_your_key

wrangler secret put ALERT_WEBHOOK_URL
# Paste your Slack or PagerDuty webhook URL

Step 2: Capture Screenshots

The worker calls the Rendex screenshot API for each URL and returns the raw PNG bytes. The waitUntil: "networkidle2" option waits for network activity to settle, which handles SPAs and lazy-loaded content.

src/capture.ts
export async function captureSnapshot(
  url: string,
  apiKey: string
): Promise<ArrayBuffer> {
  const res = await fetch("https://api.rendex.dev/v1/screenshot", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url,
      format: "png",
      width: 1280,
      height: 800,
      fullPage: false,
      waitUntil: "networkidle2",
    }),
  })

  if (!res.ok) {
    const err = (await res.json()) as { error?: string }
    throw new Error(`Capture failed for ${url}: ${err.error ?? res.status}`)
  }

  return res.arrayBuffer()
}

Step 3: Detect Visual Changes

For byte-level diffing inside a Worker, compare the first kilobyte of each PNG. PNG files are deterministic for the same content, so a byte difference signals a visual change. For pixel-accurate diffing, run pixelmatch in a Node.js step outside the Worker.

src/diff.ts
export function diffScore(a: ArrayBuffer, b: ArrayBuffer): number {
  if (a.byteLength !== b.byteLength) {
    // Size difference indicates layout or content change
    return Math.abs(a.byteLength - b.byteLength) / Math.max(a.byteLength, b.byteLength)
  }

  // Compare first 2KB of pixel data (post PNG header)
  const chunkSize = Math.min(2048, a.byteLength)
  const viewA = new Uint8Array(a, 0, chunkSize)
  const viewB = new Uint8Array(b, 0, chunkSize)

  let diff = 0
  for (let i = 0; i < chunkSize; i++) {
    if (viewA[i] !== viewB[i]) diff++
  }

  return diff / chunkSize
}

Step 4: Send Alerts on Change

When the diff score exceeds your threshold, post to your alert webhook. The example below uses the Slack incoming webhook format.

src/alert.ts
export async function sendAlert(
  webhookUrl: string,
  url: string,
  score: number
): Promise<void> {
  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      text: [
        `:warning: Visual change detected`,
        `URL: ${url}`,
        `Diff: ${(score * 100).toFixed(1)}% of sampled pixels changed`,
        `Check your site: ${url}`,
      ].join("\n"),
    }),
  })
}

Step 5: Wire Up the Cron Handler

The scheduled export is what Cloudflare calls on each cron tick. Each URL is processed in parallel with Promise.allSettled, so a failed capture on one URL does not block the others.

src/index.ts
import { captureSnapshot } from "./capture"
import { diffScore } from "./diff"
import { sendAlert } from "./alert"

interface Env {
  RENDEX_API_KEY: string
  ALERT_WEBHOOK_URL: string
  MONITORED_URLS: string
  SNAPSHOTS: KVNamespace
}

export default {
  async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
    const urls: string[] = JSON.parse(env.MONITORED_URLS)
    const threshold = 0.03  // alert at 3% diff of sampled bytes

    await Promise.allSettled(
      urls.map(async (url) => {
        const key = `snapshot:${encodeURIComponent(url)}`

        const current = await captureSnapshot(url, env.RENDEX_API_KEY)
        const baseline = await env.SNAPSHOTS.get(key, "arrayBuffer")

        if (baseline) {
          const score = diffScore(baseline, current)
          if (score > threshold) {
            await sendAlert(env.ALERT_WEBHOOK_URL, url, score)
          }
        }

        // Store current as new baseline
        await env.SNAPSHOTS.put(key, current, {
          expirationTtl: 7 * 24 * 60 * 60,  // expire after 7 days
        })
      })
    )
  },
}

Async Capture for Larger Fleets

If you monitor dozens of URLs, the synchronous approach blocks your cron until all captures complete. Use the async capture mode with webhook delivery instead: submit each capture job and return immediately. Rendex calls your endpoint when the screenshot is ready.

src/async-capture.ts
// Submit an async capture job
const res = await fetch("https://api.rendex.dev/v1/screenshot", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${env.RENDEX_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://yoursite.com",
    format: "png",
    async: true,
    webhookUrl: "https://your-worker.workers.dev/webhook",
  }),
})

const { jobId } = await res.json()
// Rendex calls your /webhook endpoint when done
console.log("Job queued:", jobId)

Your webhook receiver stores the result and queues the diff check. This pattern keeps your cron handler fast regardless of how many URLs you monitor.

For high-volume monitoring (50+ URLs per run), use the async batch rendering pattern to submit multiple URLs in a single API call and receive one webhook callback per completed capture.

Production Considerations

Baseline Resets After Deploys

After a planned deploy, expected visual changes will trigger false alerts until you reset the baseline. Add a reset step to your CI pipeline:

reset-baselines.sh
# Delete all KV keys matching your snapshot prefix
wrangler kv key list --namespace-id YOUR_KV_ID --prefix "snapshot:" | \
  jq -r '.[].name' | \
  xargs -I{} wrangler kv key delete --namespace-id YOUR_KV_ID "{}"

echo "Baselines cleared — first cron run after deploy sets new baselines"

Capturing Auth-Gated Pages

Pass session cookies in the headers parameter to capture pages behind login. See the API reference for the exact parameter shape. Do not store production session tokens inwrangler.toml. Use Wrangler secrets instead.

Call Volume and Plan Sizing

At 3 URLs on an hourly cron: 3 × 24 × 30 = 2,160 calls per month. The free tier covers 100 calls/month, enough for testing. A Starter plan covers typical small-fleet monitoring at a fixed monthly cost. See the pricing page for current limits.

Next Steps

Start with the free screenshot tool to confirm Rendex can render your pages before writing any code. Then get a free API key (100 calls/month, no credit card required) and adapt the worker above to your monitored URLs.

For pixel-accurate diffs in a GitHub Actions CI pipeline, see How to Build a Visual Regression Testing Pipeline, which covers the full pixelmatch integration and how to block merges when visual changes exceed your threshold.

If the cron worker above is more infrastructure than you want to run, Rendex Watch is the same idea built in: no KV baselines or cron triggers to manage. See automating competitor price monitoring for a worked example.

Try Rendex Free

100 screenshots/month. No credit card required.

Get API Key