<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SamReid</title>
    <description>The latest articles on DEV Community by SamReid (@samreid).</description>
    <link>https://dev.to/samreid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3943096%2F4df53961-72f0-44f3-925e-95d40321c304.png</url>
      <title>DEV Community: SamReid</title>
      <link>https://dev.to/samreid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samreid"/>
    <language>en</language>
    <item>
      <title>Your cron jobs are probably failing silently and you have no idea</title>
      <dc:creator>SamReid</dc:creator>
      <pubDate>Fri, 22 May 2026 06:44:44 +0000</pubDate>
      <link>https://dev.to/samreid/your-cron-jobs-are-probably-failing-silently-and-you-have-no-idea-3mp4</link>
      <guid>https://dev.to/samreid/your-cron-jobs-are-probably-failing-silently-and-you-have-no-idea-3mp4</guid>
      <description>&lt;p&gt;A user emailed last year to ask why their weekly export was two months stale.&lt;/p&gt;

&lt;p&gt;I looked at the cron job. It was running. The logs said it was completing successfully. Except the logs were from two months ago, because the cron job had silently stopped running two months ago and nobody had noticed because there were no alerts, no errors, no way to notice  -  the job just... wasn't there anymore.&lt;/p&gt;

&lt;p&gt;The cause was a deploy that changed an environment variable the job depended on. The job would start, hit the config error, and exit with a non-zero code. Cron would note this in syslog. Nobody was watching syslog.&lt;/p&gt;

&lt;p&gt;This is the most common category of "invisible" production failure. The job exists. You can see it in crontab. It just isn't running. And nothing is watching whether it ran.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why ping monitoring doesn't work for cron jobs
&lt;/h2&gt;

&lt;p&gt;If your service has an HTTP endpoint, you can ping it. Cron jobs don't have HTTP endpoints. You could add one  -  a &lt;code&gt;/status&lt;/code&gt; route that returns the last run time  -  but now you're building monitoring infrastructure into every job, and you still have to remember to check that endpoint on a schedule that aligns with when the job runs.&lt;/p&gt;

&lt;p&gt;The bigger problem: a ping monitor checks whether something is &lt;em&gt;reachable&lt;/em&gt;. For cron jobs, the question is whether something &lt;em&gt;happened&lt;/em&gt;. Those are completely different things.&lt;/p&gt;

&lt;p&gt;Your weekly backup job could be "reachable" in whatever sense you can ping it and get nothing, and that tells you nothing about whether the backup actually ran last Sunday at 3 AM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Heartbeat monitoring: the inverted model
&lt;/h2&gt;

&lt;p&gt;Heartbeat monitoring inverts the check. Instead of the monitor asking "is the service up?", the job tells the monitor "I finished successfully."&lt;/p&gt;

&lt;p&gt;The pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a heartbeat monitor with a period (how often the job runs) and a grace period (how long after the expected time to wait before alerting)&lt;/li&gt;
&lt;li&gt;At the end of your cron job  -  after all the work is done, on the success path  -  send an HTTP ping to the heartbeat URL&lt;/li&gt;
&lt;li&gt;If the monitor doesn't receive a ping within &lt;code&gt;period + grace&lt;/code&gt;, fire an alert&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The failure modes it catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Job didn't run at all (cron config broken, cron daemon down, environment issue)&lt;/li&gt;
&lt;li&gt;Job ran but exited early with an error before reaching the success ping&lt;/li&gt;
&lt;li&gt;Job ran, succeeded, but took longer than expected (catches jobs that are silently degrading over time)&lt;/li&gt;
&lt;li&gt;Job was removed or disabled by accident&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The beauty of it is that the monitoring lives &lt;em&gt;outside&lt;/em&gt; the job. You don't have to instrument every job heavily  -  you add one line at the end of the success path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;At the simplest level, heartbeat monitoring is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# at the end of your script, after all work is done&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--retry&lt;/span&gt; 3 &lt;span class="s2"&gt;"https://grabdiff.com/ping/your-unique-slug"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt; gives you a unique unguessable URL for each heartbeat monitor. Set the period to match your cron schedule, set a grace period (I use 15 minutes for jobs that run hourly, a few hours for daily jobs), and you're done. If the ping doesn't arrive within that window, you get an email.&lt;/p&gt;

&lt;p&gt;For application code instead of shell scripts, it's the same idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;runExportJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// do the work&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;exportData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"export failed: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// no ping sent&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// only ping on success&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HEARTBEAT_URL"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"heartbeat ping failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c"&gt;// don't fail the job over a monitoring ping failure&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ping only on the success path.&lt;/strong&gt; The monitor needs to distinguish "ran and succeeded" from "ran and failed." If you ping on both success and failure, you lose that signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't fail the job if the ping fails.&lt;/strong&gt; Your backup job shouldn't fail because the monitoring endpoint was momentarily unreachable. Log it, but keep it separate from the job's exit code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Include the job output in your error handling somewhere.&lt;/strong&gt; The heartbeat tells you the job didn't run  -  it doesn't tell you why. Make sure your job logs to somewhere you can check when an alert fires.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing period and grace
&lt;/h2&gt;

&lt;p&gt;The period should match your cron schedule exactly. If your job runs &lt;code&gt;0 3 * * *&lt;/code&gt; (3 AM daily), your period is 24 hours.&lt;/p&gt;

&lt;p&gt;Grace period is trickier. You want it long enough to not alert on jobs that take slightly longer than usual, but short enough to catch actual failures before they become a problem.&lt;/p&gt;

&lt;p&gt;My rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minute-level jobs&lt;/strong&gt;: grace = 5 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hourly jobs&lt;/strong&gt;: grace = 15 minutes
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily jobs&lt;/strong&gt;: grace = 2–4 hours (depending on how bad a missed run is)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly jobs&lt;/strong&gt;: grace = 12 hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anything with a business impact  -  billing runs, data exports, email digests  -  I keep the grace short and accept the occasional false positive from a slow run. A false positive is annoying. A missed billing run is worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  The jobs worth monitoring
&lt;/h2&gt;

&lt;p&gt;Every shop has a slightly different list, but the categories that almost always have critical unmonitored jobs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data exports and reports.&lt;/strong&gt; Whatever you're generating for customers or stakeholders on a schedule. When these stop, you find out a week later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Billing and subscription processing.&lt;/strong&gt; Failed renewal attempts, expired trial follow-ups, invoice generation. Silent failures here have direct revenue impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email digests and notifications.&lt;/strong&gt; Users set expectations based on these. When they stop arriving, your support queue fills up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database backups.&lt;/strong&gt; The one you really, really don't want to discover has been failing when you actually need a restore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search index updates.&lt;/strong&gt; If your search depends on a nightly rebuild job and the job stops, search quietly degrades until someone notices results are stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache warming and pre-computation.&lt;/strong&gt; These often run before peak traffic. If they don't run, you don't notice until peak traffic hits and things are slow.&lt;/p&gt;

&lt;p&gt;Go through your crontab right now. For each job, ask: "How would I know if this stopped running?" If the answer is "a user would tell me," that job needs a heartbeat.&lt;/p&gt;




&lt;h2&gt;
  
  
  Start/fail endpoints for richer monitoring
&lt;/h2&gt;

&lt;p&gt;Some heartbeat systems (including &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt;) support optional start and fail endpoints in addition to the success ping.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start endpoint&lt;/strong&gt;: ping when the job begins. Lets you track job duration and alert if a job runs too long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail endpoint&lt;/strong&gt;: explicit failure ping for when you want to differentiate "didn't run" from "ran and explicitly failed."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The start endpoint is the one I actually use regularly. Combined with the success ping, you get duration tracking. If a job that normally takes 3 minutes suddenly takes 45 minutes, that's worth knowing about even if it technically "succeeded."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://grabdiff.com/ping/your-slug/start"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="c"&gt;# ... do work ...&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://grabdiff.com/ping/your-slug/fail"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://grabdiff.com/ping/your-slug"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is probably more than you need for most jobs. One ping at the end of the success path is the right place to start.&lt;/p&gt;




&lt;h2&gt;
  
  
  The monitoring gap between "it exists" and "it ran"
&lt;/h2&gt;

&lt;p&gt;The broader pattern here: there's a gap between "the system is configured to do a thing" and "the thing actually happened." Ping monitors cover the first half. Heartbeat monitoring covers the second.&lt;/p&gt;

&lt;p&gt;Your cron job exists. Your renewal process is configured. Your backup job is in the schedule. The question is whether it's &lt;em&gt;running&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;For any job where the answer to "how would I know if this stopped?" is "I wouldn't," add a heartbeat. It's a one-line change to your script and a two-minute setup in your monitoring tool. The jobs I've seen cause the most damage are almost always ones where someone set them up, confirmed they ran once, and then never thought about them again until something downstream broke.&lt;/p&gt;

&lt;p&gt;The two-month-stale export was embarrassing. The fix took about 90 seconds.&lt;/p&gt;




&lt;p&gt;I wrote this because heartbeat monitoring is one of those things that nobody tells you about until after you've had the incident that makes you wish you'd known. It's not in most intro-to-devops content, and it probably should be.&lt;/p&gt;

&lt;p&gt;If you've had a cron job go silent on you  -  or if you're running jobs right now that you realize have no heartbeat after reading this  -  drop a comment. I'm also curious whether anyone has a good pattern for monitoring jobs that are supposed to &lt;em&gt;not&lt;/em&gt; run (maintenance windows, feature flags that disable background work). That one's trickier and I haven't landed on a clean solution.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>monitoring</category>
      <category>backend</category>
    </item>
    <item>
      <title>How an expired SSL cert took down our checkout for six hours (and what I should have had watching)</title>
      <dc:creator>SamReid</dc:creator>
      <pubDate>Fri, 22 May 2026 06:34:12 +0000</pubDate>
      <link>https://dev.to/samreid/how-an-expired-ssl-cert-took-down-our-checkout-for-six-hours-and-what-i-should-have-had-watching-2k17</link>
      <guid>https://dev.to/samreid/how-an-expired-ssl-cert-took-down-our-checkout-for-six-hours-and-what-i-should-have-had-watching-2k17</guid>
      <description>&lt;p&gt;The site was "up." The monitor said so. HTTP 200, response times normal, no alerts.&lt;/p&gt;

&lt;p&gt;What the monitor didn't know  -  what I didn't know  -  was that our SSL certificate had expired 87 minutes earlier and every user hitting the site was getting a certificate error in their browser. Not a down page. Not a 5xx. A cert error. The kind where browsers show a big red warning screen and most users immediately close the tab.&lt;/p&gt;

&lt;p&gt;For a checkout flow, that's about as bad as the server being down. Worse, actually, because at least a down server triggers your uptime alert.&lt;/p&gt;

&lt;p&gt;This is the post-mortem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;We were running Let's Encrypt with certbot and auto-renewal configured. The renewal was supposed to happen when the cert had 30 days left. It had been working fine for about 18 months.&lt;/p&gt;

&lt;p&gt;Then it didn't.&lt;/p&gt;

&lt;p&gt;The renewal job ran, hit a DNS validation error  -  our DNS provider had a 30-minute API hiccup that day  -  and failed silently. Certbot logged the failure, but nobody was watching certbot logs. The retry ran 12 hours later, same issue. Then it was fine. But by then, the "success" window had passed and the cert expired before the next attempt.&lt;/p&gt;

&lt;p&gt;Let's Encrypt auto-renewal fails for reasons that feel random at the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS propagation delays&lt;/strong&gt; when you're using DNS-01 validation and your DNS provider has latency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt;  -  Let's Encrypt has per-domain limits (5 failures per hour) that cause subsequent retries to also fail&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall or load balancer changes&lt;/strong&gt; that block the HTTP-01 validation path on port 80&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File permission issues&lt;/strong&gt; on the cert directory after a system update&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook or deploy hook failures&lt;/strong&gt;  -  the cert renews but the service doesn't reload to pick up the new cert&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our case it was DNS validation timing plus a log nobody was watching. The cert expired at 3:14 PM. The Slack alert  -  from a user, not a monitor  -  came in at 4:58 PM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why my uptime monitor missed it for four hours
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt; monitors SSL expiry now, which is part of why I built it. But at the time I was using a basic HTTP ping monitor. Here's what it was doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make HTTP request to our URL&lt;/li&gt;
&lt;li&gt;Check for 200 response&lt;/li&gt;
&lt;li&gt;Mark as healthy&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The problem is step 1. The monitor was connecting via HTTP (port 80) and following the redirect to HTTPS. The redirect itself returned 301, healthy. Then the HTTPS request... also returned 200? &lt;/p&gt;

&lt;p&gt;Sort of. The monitor wasn't validating the SSL certificate. It was making the HTTPS request with cert verification disabled, because false positives from cert issues in test environments made that the default in a lot of ping monitoring setups. So it dutifully checked the response code, got a 200 (from behind the expired cert that browsers were rejecting), and marked everything green.&lt;/p&gt;

&lt;p&gt;Four hours of "everything is fine."&lt;/p&gt;




&lt;h2&gt;
  
  
  What proper SSL monitoring actually checks
&lt;/h2&gt;

&lt;p&gt;SSL expiry monitoring should check a few distinct things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Certificate expiry date&lt;/strong&gt;  -  the obvious one. Get the cert's &lt;code&gt;Not After&lt;/code&gt; field and alert at configurable thresholds. I alert at 30 days and 7 days. If you're using Let's Encrypt with 90-day certs, a 30-day warning gives you two full renewal windows to fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Full-chain validation&lt;/strong&gt;  -  not just that a cert exists, but that the entire chain from your cert to the root CA is valid. Intermediate cert issues cause browser errors even when your cert itself hasn't expired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cert actually served matches expected domain&lt;/strong&gt;  -  if something went wrong with your load balancer config and it's serving the cert for a different domain, that's a browser error even with a valid cert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Port 443 is actually accepting connections&lt;/strong&gt;  -  a "port not open" situation is different from "cert expired" but both cause the same user-facing result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The cert returned matches what's on disk&lt;/strong&gt;  -  this catches the case where renewal succeeded but the service didn't reload and is still serving the old, expired cert.&lt;/p&gt;

&lt;p&gt;A ping monitor does none of these. A lot of "SSL monitoring" tools only do #1, which misses the cases that actually catch you off guard.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Monitor the cert directly, not via HTTP.&lt;/strong&gt; Connect to port 443, do the TLS handshake, and inspect the cert that's actually being served. Don't just check the expiry date  -  validate the chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set alert thresholds that give you time to fix things manually.&lt;/strong&gt; Let's Encrypt certs renew at 30 days remaining. I alert at 30 days (something's wrong with auto-renewal) and 7 days (it's still not fixed and now it's urgent). That gives me 23 days between "something's wrong" and "now panic."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the renewal logs.&lt;/strong&gt; Not the SSL cert itself, but the renewal process. Set up a heartbeat  -  certbot's &lt;code&gt;--deploy-hook&lt;/code&gt; can ping a monitoring URL on successful renewal. If the heartbeat doesn't arrive within &lt;code&gt;period + grace&lt;/code&gt;, alert. This catches the "cert renewed but didn't reload" case too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test your renewal before it matters.&lt;/strong&gt; &lt;code&gt;certbot renew --dry-run&lt;/code&gt; in your staging environment, regularly. Not just once when you set it up.&lt;/p&gt;




&lt;h2&gt;
  
  
  The monitoring stack I run now
&lt;/h2&gt;

&lt;p&gt;For SSL specifically: I use &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt; for the cert expiry checks  -  it connects directly to port 443, validates the full chain, and alerts at 30 days and 7 days with enough context to know what's actually wrong (expiry date, issuer, which check failed). &lt;/p&gt;

&lt;p&gt;For the renewal heartbeat: I have certbot's &lt;code&gt;--deploy-hook&lt;/code&gt; send a ping to a GrabDiff heartbeat monitor after each successful renewal. If it doesn't ping within 93 days (the Let's Encrypt cert lifetime plus a week), I get alerted. That catches the silent renewal failures before they become a problem.&lt;/p&gt;

&lt;p&gt;The six-hour checkout outage cost us  -  I'd rather not quantify it. The monitoring stack that would have caught it costs $9/month. That math is not complicated.&lt;/p&gt;




&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;SSL expiry is one of the most embarrassing categories of outage because it's entirely predictable. You know the cert will expire. You have the date. The only question is whether you catch it before your users do.&lt;/p&gt;

&lt;p&gt;The same is true for domain expiry, for that matter. I've seen teams let their primary domain expire because the renewal email went to a former employee's address and nobody caught it. The monitoring there is trivial  -  check the WHOIS expiry date, alert 60 days out. But people don't do it until they have to learn the hard way.&lt;/p&gt;

&lt;p&gt;If your current monitoring setup would have missed the scenario I described above  -  HTTP 200 from an expired-cert server  -  it's worth spending 20 minutes fixing that before you have your own version of this post-mortem to write.&lt;/p&gt;




&lt;p&gt;I wrote this mostly to stop myself from having to explain this incident verbally ever again. Now I can just link it.&lt;/p&gt;

&lt;p&gt;But seriously  -  SSL expiry outages are embarrassing in a specific way because they're so avoidable, and I've seen them happen to teams that clearly knew what they were doing otherwise. If you've had your own cert-expiry story (or a renewal failure that was weirder than mine), I'd like to hear it in the comments. Knowing the failure modes other people have hit is the only way to build a monitoring checklist that actually covers the real world.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>security</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>The 5 things traditional uptime monitors miss (and how to catch them)</title>
      <dc:creator>SamReid</dc:creator>
      <pubDate>Fri, 22 May 2026 06:31:48 +0000</pubDate>
      <link>https://dev.to/samreid/the-5-things-traditional-uptime-monitors-miss-and-how-to-catch-them-315m</link>
      <guid>https://dev.to/samreid/the-5-things-traditional-uptime-monitors-miss-and-how-to-catch-them-315m</guid>
      <description>&lt;p&gt;Your uptime monitor is probably green right now. That doesn't mean everything is working.&lt;/p&gt;

&lt;p&gt;HTTP ping monitors are good at one thing: checking whether your server responds. They're essentially useless for everything that happens after the response leaves your server - the JavaScript execution, the rendering, the CDN edge nodes, the client-side state that has to be right for your page to actually work.&lt;/p&gt;

&lt;p&gt;I got tired of finding out about these failures from users instead of from my monitor. That's why I built &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt;  -  it takes actual screenshots of your pages, diffs them against a known-good baseline, and emails you the diff image when something looks off. Free plan, three monitors, no card. But first, here's what it's actually catching that your current monitor can't:&lt;/p&gt;

&lt;p&gt;Here are the five categories of failures I've seen (and caused) in production that an HTTP monitor will miss completely, plus how you actually catch them.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. JavaScript crashes on load
&lt;/h2&gt;

&lt;p&gt;This is the most common silent failure on modern web apps, and the one most developers underestimate.&lt;/p&gt;

&lt;p&gt;Your server sends back valid HTML. HTTP 200, response time under 500ms, your monitor is happy. Then the client-side bundle executes. Somewhere in there - a null reference on a property that's undefined in some edge case, a third-party script that assumes something about the DOM that isn't true, an API response that came back in a shape the frontend didn't expect - an unhandled exception gets thrown. The page freezes. Or goes blank. Or renders halfway and stops.&lt;/p&gt;

&lt;p&gt;From your monitor's perspective: everything is fine.&lt;/p&gt;

&lt;p&gt;From your user's perspective: white screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What makes this nasty:&lt;/strong&gt; JavaScript errors are often &lt;em&gt;conditional&lt;/em&gt;. They affect logged-in users but not logged-out ones. They affect users on certain plans, with certain browser versions, with certain cookies or localStorage state. Your monitor is hitting the URL fresh, unauthenticated, with a clean browser - it's not in the affected cohort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it:&lt;/strong&gt; Visual monitoring - take a screenshot with a real headless browser and compare it against a known-good baseline. A blank page or partial render will show up immediately as a large pixel diff. Standard HTTP monitoring cannot catch this.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. CDN serving stale or broken content
&lt;/h2&gt;

&lt;p&gt;You fixed the bug. Deployed. Checked the origin. Everything looks correct. And then the Slack DMs start: users are still seeing the broken version.&lt;/p&gt;

&lt;p&gt;CDN cache invalidation is notoriously unreliable. The failure modes include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Purge API returned 200 but didn't actually purge&lt;/strong&gt; - this happens more than vendors want to admit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge nodes in some regions updated, others didn't&lt;/strong&gt; - your origin check hit one data center, users are hitting another&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-Control headers were wrong&lt;/strong&gt; - a &lt;code&gt;max-age=86400&lt;/code&gt; header set during a period when things were broken means users get the broken version for up to 24 more hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CDN cached a redirect or an error page&lt;/strong&gt; - your 503 from 45 minutes ago is still being served as a cached 503 with a 200 wrapper&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your HTTP monitor hits the origin directly, or hits a CDN edge node that happens to have fresh cache. Users are hitting different edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it:&lt;/strong&gt; Monitor from multiple geographic locations, and monitor what the page &lt;em&gt;looks like&lt;/em&gt;, not just what status code it returns. A CDN serving an old broken page will return HTTP 200 with content that doesn't match your current baseline. Only a visual diff will catch the discrepancy.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. React/Next.js hydration failures
&lt;/h2&gt;

&lt;p&gt;Server-side rendering gives you the best of both worlds: fast initial paint from pre-rendered HTML, then full interactivity once the JavaScript loads and "hydrates" the DOM.&lt;/p&gt;

&lt;p&gt;When hydration goes wrong, you get the worst of both worlds.&lt;/p&gt;

&lt;p&gt;The server sends perfectly rendered HTML. Your monitor checks it, sees a 200, sees the content in the response body, marks it as healthy. The user's browser receives that HTML and renders it visually - the page &lt;em&gt;looks&lt;/em&gt; fine. Then React tries to hydrate: match the server-rendered DOM against what the client-side bundle would have rendered, attach event listeners, take over control.&lt;/p&gt;

&lt;p&gt;If there's a mismatch - different data, different component state, a prop that resolves differently on client vs. server - React throws a hydration error. Depending on how bad the mismatch is, the page might: silently fail and leave the page un-interactive, throw an error and remount (causing a flash and losing state), or crash entirely.&lt;/p&gt;

&lt;p&gt;The user sees a page that looks correct but where buttons do nothing and forms don't submit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it:&lt;/strong&gt; Again, visual monitoring alone doesn't fully catch this one - a hydration failure might not visually change the page. What you really need here is headless browser monitoring that actually &lt;em&gt;interacts&lt;/em&gt; with the page, not just screenshots it. But visual monitoring at least catches the cases where hydration failures cause visible layout breaks or blank sections.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Visual regressions from deploys
&lt;/h2&gt;

&lt;p&gt;This one is subtle and often dismissed until it bites you.&lt;/p&gt;

&lt;p&gt;You deployed a CSS change that seemed harmless. Or bumped a dependency. Or refactored a component. The page still loads, still returns 200, still has all the right content in the DOM. But something &lt;em&gt;looks&lt;/em&gt; different - a font changed, a button moved, a section collapsed, a layout broke on certain viewport widths.&lt;/p&gt;

&lt;p&gt;Maybe it's minor enough that you don't notice it in manual testing. Maybe it's only visible at certain screen sizes you didn't test. Maybe it's on a page that isn't part of your standard QA flow.&lt;/p&gt;

&lt;p&gt;Users notice. Users get confused. Users don't convert. And nobody knows why conversion dropped 15% last Tuesday because it's not in any error log - it wasn't an &lt;em&gt;error&lt;/em&gt;, it was just wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it:&lt;/strong&gt; This is exactly what visual diffing is built for. Take a screenshot before and after every deploy, compare them, and require a human to approve any visual change before it goes to production. This is what end-to-end visual testing tools like Percy do for CI, and what visual uptime monitoring does for production.&lt;/p&gt;

&lt;p&gt;The key distinction: CI visual tests run on your test environment before deploy. Production visual monitoring catches the regressions that slip through - the ones that only appear with real data, real CDN behavior, or real third-party scripts.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Cron jobs and background workers silently dying
&lt;/h2&gt;

&lt;p&gt;This one doesn't get talked about enough in the uptime monitoring context, because it's not about a web page being down - it's about a process that &lt;em&gt;isn't&lt;/em&gt; running when it should be.&lt;/p&gt;

&lt;p&gt;Your nightly data export job. Your email digest cron. Your subscription renewal checker. Your database backup task. These run in the background, they don't have HTTP endpoints to ping, and when they die - because of a deploy that changed an environment variable they depended on, a library update that broke a dependency, a database connection that started timing out - they die silently.&lt;/p&gt;

&lt;p&gt;No alert. No log entry that anyone's watching. Just a job that was supposed to run at 3 AM and didn't.&lt;/p&gt;

&lt;p&gt;You find out a week later when a customer asks why their export data is a week stale. Or when your database backup is missing and you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it:&lt;/strong&gt; Heartbeat monitoring. The pattern is: your cron job sends an HTTP ping to a monitoring endpoint at the end of each successful run. If the endpoint doesn't receive a ping within &lt;code&gt;period + grace&lt;/code&gt;, it fires an alert. This inverts the monitoring model - instead of checking whether something is up, you're checking whether something ran.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# At the end of your cron job&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"https://grabdiff.com/ping/your-monitor-slug"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that ping doesn't arrive on schedule, you get alerted. It's simple, it's reliable, and it catches the entire class of "background job died silently" failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  The full picture
&lt;/h2&gt;

&lt;p&gt;A complete monitoring setup that actually catches production failures looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server responds&lt;/td&gt;
&lt;td&gt;HTTP ping (Pingdom, UptimeRobot)&lt;/td&gt;
&lt;td&gt;Server down, DNS broken, TLS expired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page renders correctly&lt;/td&gt;
&lt;td&gt;Visual screenshot monitor&lt;/td&gt;
&lt;td&gt;JS crashes, blank pages, CDN stale cache, visual regressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron jobs run&lt;/td&gt;
&lt;td&gt;Heartbeat monitor&lt;/td&gt;
&lt;td&gt;Silent background job failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL/domain expiry&lt;/td&gt;
&lt;td&gt;Certificate monitor&lt;/td&gt;
&lt;td&gt;Expiring certs, domain renewals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You need all four layers. HTTP ping is necessary but covers maybe 40% of what actually goes wrong. Visual monitoring and heartbeat monitoring cover most of the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;After running into enough of these failures - mostly categories 1, 2, and 5 - I built &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt; to handle the visual monitoring and heartbeat pieces alongside standard uptime checks.&lt;/p&gt;

&lt;p&gt;It screenshots your URLs on a schedule using headless Chrome, diffs them against a baseline, and sends you the diff image in an alert when something changes. It also handles heartbeat monitoring for cron jobs and background workers, and tracks SSL/domain expiry.&lt;/p&gt;

&lt;p&gt;Free plan covers three monitors. If you're runnin&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to build a visual uptime monitor with Go and headless Chrome</title>
      <dc:creator>SamReid</dc:creator>
      <pubDate>Fri, 22 May 2026 06:25:34 +0000</pubDate>
      <link>https://dev.to/samreid/how-to-build-a-visual-uptime-monitor-with-go-and-headless-chrome-16nj</link>
      <guid>https://dev.to/samreid/how-to-build-a-visual-uptime-monitor-with-go-and-headless-chrome-16nj</guid>
      <description>&lt;p&gt;Most uptime monitors work by making an HTTP request and checking the response code. It's fast, cheap, and catches about half the things that actually go wrong in production.&lt;/p&gt;

&lt;p&gt;The other half - JavaScript crashes, CDN serving stale cache, React hydration failures, missing elements - only show up when you look at what the page actually &lt;em&gt;renders&lt;/em&gt;, not what the server &lt;em&gt;returns&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is a walkthrough of how I built &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt; - the visual monitoring piece specifically: capture screenshots with headless Chrome, diff them against a baseline, and send an alert when something looks wrong. I'll use Go and &lt;a href="https://github.com/chromedp/chromedp" rel="noopener noreferrer"&gt;chromedp&lt;/a&gt;, which is what GrabDiff runs under the hood. If you'd rather just use the thing than build it, GrabDiff has a free plan with three monitors and no card required.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The core loop is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On a schedule, capture a screenshot of a URL using headless Chrome&lt;/li&gt;
&lt;li&gt;Compare it pixel-by-pixel against a stored baseline image&lt;/li&gt;
&lt;li&gt;If the diff percentage exceeds a threshold, send an alert with the diff image attached&lt;/li&gt;
&lt;li&gt;Otherwise, store the new screenshot (optionally updating the baseline over time)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The interesting engineering is in steps 2 and 3 - getting the diff right and making alerts actionable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Capturing screenshots with chromedp
&lt;/h2&gt;

&lt;p&gt;chromedp is a Go library that controls Chrome via the DevTools Protocol. It handles the browser lifecycle, navigation, and screenshot capture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;screenshot&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/chromedp/chromedp"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultExecAllocatorOptions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"headless"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"disable-gpu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"no-sandbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WindowSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;800&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;allocCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewExecAllocator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allocCtx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c"&gt;// wait for JS to settle&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FullScreenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;chromedp.Sleep(2*time.Second)&lt;/code&gt;&lt;/strong&gt; is a blunt instrument but effective. For most pages, 2 seconds is enough for the JavaScript to execute and the page to reach a stable state. For pages with complex async data fetching you might need more, or you can use &lt;code&gt;chromedp.WaitVisible&lt;/code&gt; to wait for a specific element.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;chromedp.FullScreenshot&lt;/code&gt;&lt;/strong&gt; captures the entire page height, not just the viewport. This is usually what you want for monitoring - you care about the whole page, not just what happens to be visible above the fold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 90 in &lt;code&gt;FullScreenshot&lt;/code&gt;&lt;/strong&gt; is JPEG quality. You can use &lt;code&gt;chromedp.CaptureScreenshot&lt;/code&gt; instead for PNG (larger files, lossless).&lt;/p&gt;




&lt;h2&gt;
  
  
  Pixel diffing
&lt;/h2&gt;

&lt;p&gt;Once you have a screenshot, you need to compare it against the baseline. The core operation is straightforward: decode both images, iterate over pixels, count how many differ by more than some per-channel threshold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;screenshot&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"bytes"&lt;/span&gt;
    &lt;span class="s"&gt;"image"&lt;/span&gt;
    &lt;span class="s"&gt;"image/color"&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="s"&gt;"image/jpeg"&lt;/span&gt;
    &lt;span class="s"&gt;"image/png"&lt;/span&gt;
    &lt;span class="s"&gt;"math"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;DiffResult&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;DiffPercent&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;DiffImage&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="c"&gt;// PNG with differences highlighted&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DiffResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;baseImg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;currImg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;bounds&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;baseImg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bounds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;diffImg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRGBA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;diffPixels&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;totalPixels&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dx&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;br&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;baseImg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;At&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;cr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;currImg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;At&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

            &lt;span class="c"&gt;// RGBA() returns values in [0, 65535]&lt;/span&gt;
            &lt;span class="n"&gt;dr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;br&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;dg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

            &lt;span class="c"&gt;// threshold: 10% channel difference (6553 out of 65535)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dr&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;6553&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;dg&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;6553&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;6553&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;diffPixels&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
                &lt;span class="c"&gt;// highlight in red&lt;/span&gt;
                &lt;span class="n"&gt;diffImg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c"&gt;// keep original, slightly dimmed for context&lt;/span&gt;
                &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;currImg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;At&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;diffImg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;diffPercent&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diffPixels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalPixels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Buffer&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diffImg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;DiffResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;DiffPercent&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;diffPercent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;DiffImage&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bytes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The diff image produced here shows changed pixels in red against a dimmed version of the current screenshot. This gives you at a glance where the change is - useful when you're trying to tell whether it's a minor layout shift or something more serious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On threshold tuning:&lt;/strong&gt; 1% is a good starting point for &lt;code&gt;DiffPercent&lt;/code&gt;. Anything above that is almost certainly a real change. Below 0.1% is usually antialiasing noise. The right number depends on how dynamic your pages are.&lt;/p&gt;




&lt;h2&gt;
  
  
  Storing baselines
&lt;/h2&gt;

&lt;p&gt;You need to store the baseline image somewhere. For a simple setup, an object store (S3, Backblaze B2, R2) works well - store the baseline under a key like &lt;code&gt;{monitor_id}/baseline.jpg&lt;/code&gt; and update it when the user explicitly marks a new baseline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// On first check, or when user resets the baseline&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SetBaseline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitorID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s/baseline.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitorID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"image/jpeg"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// On each check&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetBaseline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitorID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s/baseline.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitorID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One design decision worth thinking about: should the baseline update automatically? There are arguments either way. If you update it automatically after every "clean" check, you adapt to intentional page changes without manual intervention. If you require explicit resets, every change that slips past your threshold accumulates silently, and you'll eventually be diffing against something that looks nothing like your original known-good state.&lt;/p&gt;

&lt;p&gt;GrabDiff requires explicit baseline resets. The reasoning: if you're updating the baseline automatically, you can drift into a state where "clean" means "whatever the page looked like yesterday" rather than "the page as I intended it." Explicit resets keep you honest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Alerting
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;DiffPercent&lt;/code&gt; exceeds your threshold, you want to notify someone fast. The two most useful channels are email (with the diff image attached) and webhooks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Alert&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;MonitorURL&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;DiffPercent&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;DiffImage&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;
    &lt;span class="n"&gt;CheckedAt&lt;/span&gt;   &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;EmailSender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SendAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Visual change detected on %s&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Diff: %.2f%% of pixels changed&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Checked at: %s&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;See attached diff image."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MonitorURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DiffPercent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckedAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RFC1123&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gomail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"From"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"To"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Subject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[GrabDiff] Change detected: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MonitorURL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"text/plain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AttachReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"diff.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DiffImage&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dialer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DialAndSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The diff image as an attachment is the key thing. An alert that just says "something changed" is nearly useless - you have to go look at the site yourself to know if it matters. An alert with a diff image attached tells you immediately whether this is "someone changed a button color" or "the entire main content area is gone."&lt;/p&gt;




&lt;h2&gt;
  
  
  Scheduling
&lt;/h2&gt;

&lt;p&gt;For the scheduling loop, a simple approach is a ticker per monitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt; &lt;span class="n"&gt;Monitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;C&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"check failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"monitor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything beyond a handful of monitors, you'll want a proper job queue (River, Asynq, or even a simple Postgres-backed queue) rather than in-process goroutines. In-process schedulers don't survive restarts gracefully and make horizontal scaling harder.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tradeoffs you'll hit in production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;False positives from dynamic content.&lt;/strong&gt; Timestamps, "last updated" labels, live counters, personalized greetings - these change on every screenshot and will trigger alerts constantly. You either need to mask those regions before diffing, or accept a higher threshold that makes you insensitive to small changes everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Headless Chrome resource usage.&lt;/strong&gt; A single Chrome instance capturing a screenshot uses roughly 200-400MB of RAM and takes 3-8 seconds depending on page complexity. If you're running hundreds of monitors at frequent intervals, you need a pool of browser instances and careful scheduling to avoid spiking resource usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication.&lt;/strong&gt; Monitoring pages behind a login requires scripting the auth flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://app.example.com/login"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SendKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`input[name="email"]`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SendKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`input[name="password"]`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`button[type="submit"]`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`#dashboard`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetURL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FullScreenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, but it's fragile - login flows change, CAPTCHAs appear, session handling has edge cases. Plan for maintenance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSRF.&lt;/strong&gt; If you're accepting URLs from users, you need to validate them against a blocklist before passing them to Chrome. Users will point your monitor at &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; or internal network addresses. Validate the resolved IP against RFC 1918 and link-local ranges before making any request.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this gets you
&lt;/h2&gt;

&lt;p&gt;A working version of the above - capture, diff, alert - is maybe 500 lines of Go. It'll catch blank pages, missing elements, major layout regressions, and CDN serving stale content. It won't catch every failure, but it catches the ones that HTTP monitors miss entirely.&lt;/p&gt;

&lt;p&gt;If you want to run it yourself, the full approach is essentially what I described. If you'd rather not manage the Chrome instances and the storage and the scheduling, I built &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt; to handle all of that - it does the screenshot diffing, sends you the diff image in the alert, and handles SSL/domain/cron monitoring alongside the visual checks. Free plan, three monitors, no credit card.&lt;/p&gt;

&lt;p&gt;The point is that "HTTP 200" and "the page works" are not the same thing, and the gap between them is where the interesting production failures live. Visual monitoring is how you close that gap.&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why your uptime monitor says everything's fine while users see a white screen</title>
      <dc:creator>SamReid</dc:creator>
      <pubDate>Thu, 21 May 2026 01:36:09 +0000</pubDate>
      <link>https://dev.to/samreid/why-your-uptime-monitor-says-everythings-fine-while-users-see-a-white-screen-2j0d</link>
      <guid>https://dev.to/samreid/why-your-uptime-monitor-says-everythings-fine-while-users-see-a-white-screen-2j0d</guid>
      <description>&lt;p&gt;It was 11:47 PM on a Thursday when the Slack messages started rolling in.&lt;/p&gt;

&lt;p&gt;"Hey, the checkout page looks broken."&lt;/p&gt;

&lt;p&gt;"Is the site down? I'm seeing a blank screen."&lt;/p&gt;

&lt;p&gt;"Tried three different browsers, same thing."&lt;/p&gt;

&lt;p&gt;I pulled up our uptime monitor. Green. Every check passing. Response times normal. HTTP 200 across the board. By every metric the monitor tracked, the site was healthy.&lt;/p&gt;

&lt;p&gt;It was not healthy.&lt;/p&gt;

&lt;p&gt;The checkout page - the single most important page in the entire application - was rendering a completely white screen for every user. Had been for about 40 minutes. Our monitoring had no idea.&lt;/p&gt;

&lt;p&gt;That night cost us somewhere around four hours of investigation, a patch at 2 AM, and a post-mortem I'd rather forget. The root cause was a JavaScript runtime error triggered by a third-party A/B testing script that loaded asynchronously, after our monitor's simple HTTP check had already returned 200 and moved on.&lt;/p&gt;

&lt;p&gt;The monitor was doing exactly what it was designed to do. That was the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a ping monitor actually checks
&lt;/h2&gt;

&lt;p&gt;Let's be precise about what most uptime monitoring tools do, because I think a lot of developers have a fuzzy mental model here.&lt;/p&gt;

&lt;p&gt;A basic uptime monitor makes an HTTP request to your URL. It checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did the server respond?&lt;/li&gt;
&lt;li&gt;Was the status code in the 2xx range?&lt;/li&gt;
&lt;li&gt;Was the response time under some threshold?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Some monitors add a string match - "check that the response body contains the word 'homepage'" or whatever. That's slightly better. But not by much.&lt;/p&gt;

&lt;p&gt;The thing is, your users don't care about any of that. They care whether the page &lt;em&gt;works&lt;/em&gt; - whether the content they expect is visible, the buttons they need to click are there, and the thing they're trying to do is actually possible.&lt;/p&gt;

&lt;p&gt;An HTTP 200 response with a blank body is still an HTTP 200. A page that returns your shell HTML but fails to mount any React components is still an HTTP 200. A cached CDN response that's three weeks stale is still an HTTP 200.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure modes nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Here are the actual production incidents I've seen (or personally caused) that a ping monitor will completely miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JavaScript crash on load&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your server renders the page and sends back valid HTML. But somewhere in the client-side bundle, there's an unhandled exception - a null reference, a failed import, an API that returned an unexpected shape. The page stays blank, or partially rendered, or stuck in a loading state. The server was fine. The HTTP response was fine. The user experience was not fine.&lt;/p&gt;

&lt;p&gt;These are especially nasty because they often only affect certain browsers, certain screen sizes, or users in certain states (logged in vs. logged out, items in cart vs. empty cart).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CDN serving stale content&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You deployed a fix. Your origin server is running the new code. But your CDN edge nodes are still serving the old, broken version to 80% of your users because cache invalidation didn't propagate correctly, or someone forgot to add a cache-busting header, or the CDN's purge API returned 200 but didn't actually do the thing.&lt;/p&gt;

&lt;p&gt;Your monitor hits the origin. Clean. Users hit the CDN edge. Broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React/Next.js hydration failure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You're server-side rendering a React app. The server sends down HTML, which looks great in your monitor's response check. Then the client-side JavaScript tries to "hydrate" that HTML - attach event listeners, reconcile state - and something goes wrong. The page looks rendered, but nothing is interactive. Buttons don't respond. Forms won't submit. The hydration error is sitting in the browser console where nobody ever looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An A/B test or feature flag went sideways&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Someone enabled a new variation in your experimentation platform. It works fine for 90% of users. For 10% - the control group, or a specific segment, or users with a certain cookie - it injects a script that breaks layout, hides a critical element, or throws an error. Your monitor isn't in that segment. Your monitor sees the happy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The checkout button just... isn't there anymore&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A CSS change. A template logic error. A component that conditionally renders based on some state that's slightly wrong. Your add-to-cart button, your checkout button, your submit form - just gone. The page looks fine at a glance. The hero image is there, the nav is there, the product photos are there. But the one element users actually need to convert? Missing.&lt;/p&gt;

&lt;p&gt;A monitor checking for HTTP 200 and a response time under 2 seconds has no idea.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why visual monitoring is conceptually different
&lt;/h2&gt;

&lt;p&gt;The insight is simple: instead of asking "did the server respond?", ask "does the page look right?"&lt;/p&gt;

&lt;p&gt;Visual monitoring takes a screenshot of your page - using a real browser, running real JavaScript, waiting for render to complete - and compares it against a known-good baseline. If the current screenshot differs from the baseline beyond some threshold, something has changed and you get an alert.&lt;/p&gt;

&lt;p&gt;This catches all the scenarios above because it's evaluating the end result that users actually see, not an intermediate HTTP handshake that happens before any of the interesting rendering work occurs.&lt;/p&gt;

&lt;p&gt;The diff approach is also useful beyond pure "is it broken" detection. It catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Layout shifts you didn't intentionally make&lt;/li&gt;
&lt;li&gt;Text that changed in a way it shouldn't have (wrong price, wrong copy)&lt;/li&gt;
&lt;li&gt;Elements that disappeared&lt;/li&gt;
&lt;li&gt;New elements that appeared (a cookie banner blocking content, an error message you didn't know was showing)&lt;/li&gt;
&lt;li&gt;Visual regressions from a deploy that seemed fine but quietly broke something&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key is the baseline. You take a screenshot when everything is known to be working, mark it as the baseline, and then every subsequent check compares against that. When the diff exceeds your threshold, alert.&lt;/p&gt;

&lt;p&gt;The alert should include the diff image - not just "something changed," but a visual showing exactly what changed, highlighted. That's the thing that makes it immediately actionable instead of just alarming.&lt;/p&gt;




&lt;h2&gt;
  
  
  The practical gotchas
&lt;/h2&gt;

&lt;p&gt;Visual monitoring isn't magic, and there are real tradeoffs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic content.&lt;/strong&gt; If your page shows the current time, a live stock price, or a user-specific greeting, those will show up as diffs on every single check. You need to either mask those regions, or structure your pages so dynamic content is in predictable locations you can exclude.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rendering variability.&lt;/strong&gt; Fonts rendering slightly differently on different machines, antialiasing differences, animated elements caught mid-transition - all of these can cause false positives if your diff threshold is too sensitive. You need a threshold that's high enough to ignore noise but low enough to catch real problems. Getting this calibrated takes some tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth-gated pages.&lt;/strong&gt; If you want to monitor pages behind a login, you need to handle authentication in your screenshot flow. This is doable - you can script login sequences - but it adds complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost of headless Chrome.&lt;/strong&gt; Running Chrome instances at scale is not free. It's memory-hungry, and taking screenshots takes real time compared to a simple HTTP request. A 1-minute check interval for many URLs is meaningfully more expensive to operate than a ping monitor.&lt;/p&gt;

&lt;p&gt;These are all solvable problems, but they're real, and anyone selling you a visual monitoring solution that doesn't mention them is glossing over the hard parts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;I'm not saying throw out your existing uptime monitor. Ping monitors are cheap, fast, and useful for catching the simplest failures - server down, TLS expired, DNS broken. Keep them.&lt;/p&gt;

&lt;p&gt;But treat them for what they are: a necessary but not sufficient condition for "the site is working." The gap between "the server responded with 200" and "users can actually use this page" is where a lot of production incidents live, and most teams don't find out about those incidents until users tell them.&lt;/p&gt;

&lt;p&gt;After the Thursday night incident I described at the start, I spent a weekend investigating visual monitoring options and ended up down a rabbit hole of how headless Chrome screenshot diffing actually works. None of the existing tools felt quite right - either too expensive, too complex to configure, or not quite targeted at the "is this page visually broken" question.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://grabdiff.com" rel="noopener noreferrer"&gt;GrabDiff&lt;/a&gt;. It takes screenshots of your URLs on a schedule using headless Chrome, diffs them against a stored baseline, and emails you with the diff image attached when something looks off. Free plan covers three monitors, no credit card required.&lt;/p&gt;

&lt;p&gt;Your uptime monitor is probably lying to you right now. Hopefully about something minor.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
