Why HTTP Checks Aren't Enough
Your uptime monitor says 200 OK. But the page shows a blank white screen because a JavaScript bundle failed to load. Or the checkout button is hidden behind an overlapping element after a CSS deploy. Or the CDN is serving a stale error page with a 200 status.
These are real production incidents that HTTP status checks miss completely. Visual monitoring — actually capturing what the page looks like — catches them.
Common failures that HTTP monitoring misses:
- JavaScript bundle failed to load → blank or broken page, 200 status
- Third-party script blocking render → spinner stuck, 200 status
- CSS deploy broke layout → content unreadable, 200 status
- A/B test gone wrong → wrong variant showing, 200 status
- CDN serving stale error page → cached error, 200 status
- Database error hidden behind a 200 → "Something went wrong" UI, 200 status
Architecture: Screenshot Monitoring Pipeline
A visual monitoring system has four components:
- Scheduler — runs checks on a cron interval (every 5-15 minutes)
- Capture — takes a screenshot of the target URL
- Compare — diffs the new screenshot against a baseline
- Alert — notifies the team if the diff exceeds a threshold
Build 1: Node.js + SnapAPI + Cron
// monitor.js — runs on a cron schedule
import fetch from 'node-fetch';
import { createCanvas, loadImage } from 'canvas';
import fs from 'fs/promises';
import path from 'path';
import nodemailer from 'nodemailer';
const SNAPAPI_KEY = process.env.SNAPAPI_KEY;
const BASELINE_DIR = './baselines';
const DIFF_THRESHOLD = 0.05; // 5% pixel difference triggers alert
const MONITORS = [
{ name: 'Homepage', url: 'https://yourdomain.com', selector: null },
{ name: 'Checkout', url: 'https://yourdomain.com/checkout', selector: '.checkout-form' },
{ name: 'Pricing', url: 'https://yourdomain.com/pricing', selector: null },
];
async function captureScreenshot(url) {
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'X-Api-Key': SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
full_page: false,
viewport_width: 1440,
viewport_height: 900,
wait_until: 'networkidle',
format: 'png',
}),
});
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
async function compareImages(baseline, current) {
const [img1, img2] = await Promise.all([
loadImage(baseline),
loadImage(current),
]);
const canvas = createCanvas(img1.width, img1.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img1, 0, 0);
const data1 = ctx.getImageData(0, 0, img1.width, img1.height).data;
ctx.drawImage(img2, 0, 0);
const data2 = ctx.getImageData(0, 0, img1.width, img1.height).data;
let different = 0;
for (let i = 0; i < data1.length; i += 4) {
const rDiff = Math.abs(data1[i] - data2[i]);
const gDiff = Math.abs(data1[i+1] - data2[i+1]);
const bDiff = Math.abs(data1[i+2] - data2[i+2]);
if (rDiff + gDiff + bDiff > 30) different++;
}
const totalPixels = img1.width * img1.height;
return different / totalPixels; // fraction different
}
async function sendAlert(monitor, diffRatio, currentScreenshot) {
const mailer = nodemailer.createTransport({
host: 'smtp.gmail.com', port: 587,
auth: { user: process.env.ALERT_EMAIL, pass: process.env.ALERT_PASS }
});
await mailer.sendMail({
from: 'monitor@yourdomain.com',
to: 'team@yourdomain.com',
subject: `⚠️ Visual change detected: ${monitor.name} (${(diffRatio * 100).toFixed(1)}% diff)`,
html: \`Visual change detected on \${monitor.name} (\${monitor.url})
Pixel difference: \${(diffRatio * 100).toFixed(2)}%
Screenshot attached — compare with your baseline.
\`,
attachments: [{
filename: 'current.png',
content: currentScreenshot,
}],
});
}
async function runMonitor(monitor) {
const baselinePath = path.join(BASELINE_DIR, \`\${monitor.name.replace(/\s/g, '-')}-baseline.png\`);
const current = await captureScreenshot(monitor.url);
try {
const baseline = await fs.readFile(baselinePath);
const diffRatio = await compareImages(baseline, current);
if (diffRatio > DIFF_THRESHOLD) {
console.log(\`⚠️ \${monitor.name}: \${(diffRatio * 100).toFixed(1)}% visual diff — alerting\`);
await sendAlert(monitor, diffRatio, current);
} else {
console.log(\`✓ \${monitor.name}: \${(diffRatio * 100).toFixed(2)}% diff (OK)\`);
}
} catch (err) {
if (err.code === 'ENOENT') {
// First run — save as baseline
await fs.mkdir(BASELINE_DIR, { recursive: true });
await fs.writeFile(baselinePath, current);
console.log(\`📸 \${monitor.name}: baseline saved\`);
} else {
throw err;
}
}
}
// Run all monitors
Promise.all(MONITORS.map(runMonitor))
.then(() => console.log('Monitor run complete'))
.catch(err => { console.error(err); process.exit(1); });
# Run every 15 minutes via cron
*/15 * * * * /usr/bin/node /app/monitor.js >> /var/log/monitor.log 2>&1
# Or with GitHub Actions (free):
# .github/workflows/monitor.yml
# schedule: - cron: '*/15 * * * *'
Build 2: Python Visual Monitor
import os
import math
import requests
from PIL import Image, ImageChops
from io import BytesIO
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
from email.mime.text import MIMEText
from pathlib import Path
SNAPAPI_KEY = os.environ['SNAPAPI_KEY']
BASELINE_DIR = Path('./baselines')
BASELINE_DIR.mkdir(exist_ok=True)
DIFF_THRESHOLD = 0.05 # 5%
MONITORS = [
{'name': 'homepage', 'url': 'https://yourdomain.com'},
{'name': 'pricing', 'url': 'https://yourdomain.com/pricing'},
{'name': 'login', 'url': 'https://yourdomain.com/login'},
]
def capture_screenshot(url: str) -> bytes:
r = requests.post(
'https://api.snapapi.pics/v1/screenshot',
json={
'url': url,
'full_page': False,
'viewport_width': 1440,
'viewport_height': 900,
'wait_until': 'networkidle',
'format': 'png',
},
headers={'X-Api-Key': SNAPAPI_KEY},
timeout=30,
)
r.raise_for_status()
return r.content
def pixel_diff_ratio(img1_bytes: bytes, img2_bytes: bytes) -> float:
img1 = Image.open(BytesIO(img1_bytes)).convert('RGB')
img2 = Image.open(BytesIO(img2_bytes)).convert('RGB').resize(img1.size)
diff = ImageChops.difference(img1, img2)
pixels = list(diff.getdata())
total = len(pixels)
significant = sum(1 for r, g, b in pixels if r + g + b > 30)
return significant / total
def send_slack_alert(monitor_name: str, url: str, diff_pct: float, screenshot: bytes):
"""Send to Slack webhook with screenshot."""
import base64
requests.post(os.environ['SLACK_WEBHOOK'], json={
'text': f'⚠️ *Visual change on {monitor_name}*\n{url}\nPixel diff: {diff_pct:.1f}%\n_Screenshot attached below_',
})
# For actual image attach, use Slack Files API
def run_monitor(monitor: dict):
name = monitor['name']
url = monitor['url']
baseline_path = BASELINE_DIR / f'{name}.png'
current = capture_screenshot(url)
if not baseline_path.exists():
baseline_path.write_bytes(current)
print(f'📸 {name}: baseline saved')
return
baseline = baseline_path.read_bytes()
diff = pixel_diff_ratio(baseline, current)
diff_pct = diff * 100
if diff > DIFF_THRESHOLD:
print(f'⚠️ {name}: {diff_pct:.1f}% diff — alerting!')
send_slack_alert(name, url, diff_pct, current)
# Save for debugging
Path(f'./diffs/{name}-{int(time.time())}.png').write_bytes(current)
else:
print(f'✓ {name}: {diff_pct:.2f}% diff (OK)')
for monitor in MONITORS:
try:
run_monitor(monitor)
except Exception as e:
print(f'Error monitoring {monitor["name"]}: {e}')
Monitoring Cadence by Use Case
| Use case | Check interval | Monthly calls (3 URLs) | SnapAPI plan |
|---|---|---|---|
| E-commerce (critical) | Every 5 min | ~26,000 | Pro $79/mo |
| SaaS dashboard | Every 15 min | ~8,640 | Pro $79/mo |
| Marketing site | Hourly | ~2,160 | Starter $19/mo |
| Competitor tracking | Daily | ~90 | Free tier |
Advanced: AI-Powered Change Detection
For smarter monitoring that can describe what changed (not just that something changed), use SnapAPI's AI analysis endpoint:
async function analyzeChange(url) {
const res = await fetch('https://api.snapapi.pics/v1/analyze', {
method: 'POST',
headers: { 'X-Api-Key': SNAPAPI_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
prompt: 'Describe the current state of this page. Note any error messages, broken layouts, missing content, or unusual elements that would indicate a problem.',
include_screenshot: true,
}),
});
const { analysis, screenshot } = await res.json();
return { analysis, screenshot };
}
// On incident: get AI description of what's broken
const { analysis, screenshot } = await analyzeChange('https://yourdomain.com');
await slack.send(\`🤖 AI analysis: \${analysis}\`);
// → "The page shows a white screen with a JavaScript error in the console.
// The main navigation is absent and no content is visible."
GitHub Actions supports scheduled workflows with cron expressions. A free Actions runner can run your screenshot monitor every 15 minutes with no server needed. Store baselines in the repo or an S3 bucket, and you have a complete visual monitoring stack for the cost of the SnapAPI calls alone.
Summary
Visual monitoring is a 2-hour project that catches entire categories of production failures that HTTP checks miss. The stack: cron + SnapAPI for screenshots + pixel diff + Slack/email alerts. SnapAPI's managed browser means no Chromium to maintain in your monitoring infrastructure.
Get started with 200 free calls/month — enough to monitor your homepage hourly for a week.