June 8, 2021, 9:58 UTC. Fastly pushes a configuration change. A latent bug in their Varnish-based edge detonates globally. For the next hour, Reddit, Amazon, The New York Times, The Guardian, CNN, Gov.uk, Twitch, Shopify, and Stack Overflow are all broken.
During that hour, every one of those sites’ uptime monitors reported them as up.
Not “up but degraded”. Not “warning”. Up. HTTP 200 OK.
This is not a bug in any specific uptime monitor. It is a fundamental category limit of how they work. Here is the technical gap.
What an uptime monitor actually checks
Strip a typical uptime monitor down to its core and you get this:
resp, err := http.Get("https://example.com/")
if err != nil {
return Down
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return Up
}
return Down
The monitor sends one HTTP request to your origin. It reads the status code. It reads nothing else. It never parses the response body. It never fetches anything the body references.
Now imagine the HTML your server returns:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.example.com/app.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/react.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script src="https://cdn.example.com/app.js"></script>
</body>
</html>
When your server is “up” but cdn.example.com is returning 503, the monitor makes one request, gets 200, moves on. Your actual users:
- Get the HTML — 200 OK.
- Request
app.css— 503. - Request
react.js— 503. - Request the Google Font stylesheet — maybe OK, maybe 503.
- Request
app.js— 503. - See a broken white page with unstyled text and no interactivity.
Zero of those failures are visible to the monitor, because the monitor never made request 2 through 5.
The category gap
There are two fundamentally different failure modes, and uptime monitors only see the first one:
| Failure | Origin returns | Dependencies return | Monitor sees | User sees |
|---|---|---|---|---|
| Origin down | 500 / timeout | (not attempted) | Down | Down |
| Dependency down | 200 with references | Some 503 / timeout | Up | Broken page |
The monitor’s world model is “HTTP 200 OK == working”. That was approximately true in 2005. In 2025, per the HTTP Archive 2024 Web Almanac, the median mobile page loads 22 third-party resources and 94% of mobile pages load at least one. Most sites are one broken CDN away from looking dead to their users, and the monitor cannot tell.
What you actually need to check
A monitor that catches dependency failures needs to:
- Fetch the origin page — like a normal monitor.
- Parse the returned HTML.
- Extract every resource URL the page references.
- Fetch each of those URLs independently.
- Report any that fail.
- Do all of the above from multiple regions, because CDN failures are often regional.
Step 3 is where it gets interesting.
What counts as a “resource”?
Anything the browser will try to fetch to render the page. In HTML, that is at least:
| Element | Attribute | What it loads |
|---|---|---|
<script> | src | JavaScript |
<link rel="stylesheet"> | href | CSS |
<link rel="preload"> | href | Preloaded assets |
<img> | src | Images |
<img> | srcset | Responsive images (multiple URLs per attribute) |
<source> | src / srcset | Video, audio, picture sources |
<iframe> | src | Embedded documents |
<link rel="icon"> | href | Favicon |
<link rel="manifest"> | href | PWA manifest |
And once you have loaded the CSS files, there is another recursive layer: @import, url(...), and src in @font-face declarations. A complete dependency parser needs to fetch and parse CSS to find web fonts and background images.
Parsing HTML for dependencies in Go
Use golang.org/x/net/html — a real HTML5 parser — not a regex. Regexes on HTML are famously a trap, and the edge cases will bite you on the first real-world page.
import (
"net/http"
"net/url"
"strings"
"golang.org/x/net/html"
)
func extractDependencies(pageURL string) ([]string, error) {
resp, err := http.Get(pageURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
doc, err := html.Parse(resp.Body)
if err != nil {
return nil, err
}
base, _ := url.Parse(pageURL)
var deps []string
seen := make(map[string]bool)
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "script":
if src := attr(n, "src"); src != "" {
add(&deps, seen, resolve(base, src))
}
case "link":
rel := attr(n, "rel")
if rel == "stylesheet" || rel == "preload" || rel == "icon" || rel == "manifest" {
if href := attr(n, "href"); href != "" {
add(&deps, seen, resolve(base, href))
}
}
case "img":
if src := attr(n, "src"); src != "" {
add(&deps, seen, resolve(base, src))
}
if srcset := attr(n, "srcset"); srcset != "" {
for _, u := range parseSrcset(srcset) {
add(&deps, seen, resolve(base, u))
}
}
case "iframe":
if src := attr(n, "src"); src != "" {
add(&deps, seen, resolve(base, src))
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return deps, nil
}
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
func resolve(base *url.URL, ref string) string {
if strings.HasPrefix(ref, "data:") {
return "" // skip inline data URLs
}
refURL, err := url.Parse(ref)
if err != nil {
return ""
}
return base.ResolveReference(refURL).String()
}
func add(deps *[]string, seen map[string]bool, u string) {
if u == "" || seen[u] {
return
}
seen[u] = true
*deps = append(*deps, u)
}
This gets you the static resource graph. It is not complete — it misses anything loaded by JavaScript at runtime — but it catches the assets that matter for first-paint rendering, which is where most CDN-outage breakage actually hurts users.
The gotchas
JavaScript-rendered dependencies are invisible to a parser. If a React or Vue app fetches an API after mount, that is a runtime call the HTML parser never sees. Catching those requires a headless browser (Playwright, Puppeteer) that executes the JS and captures every network request. That is far heavier to run at scale, which is why most dependency monitors start with the HTML-parser approach and only use a headless browser for the pages that genuinely need it.
Same-origin resources are not interesting. https://example.com/main.js is served by your own origin. If your origin is up, it is up. The dependencies worth tracking separately are the ones on a different origin — those are the ones that can fail independently of your server and that your uptime monitor will never see.
srcset is a trap. The value looks like image-small.jpg 480w, image-large.jpg 1024w — multiple URLs in one attribute separated by commas. A naïve split-on-comma fails because URLs themselves can contain commas (inside query strings, or data: URIs). Use a proper srcset parser, or you will intermittently break on real-world pages.
Lazy-loaded resources are still dependencies. loading="lazy" on <img> or <iframe> means the browser will not fetch the resource until it scrolls into view, but the URL is still in the HTML, so a parser-based monitor still finds it and still notices when it fails.
What about Subresource Integrity?
Subresource Integrity — the integrity="sha384-..." attribute on <script> and <link> tags — is a related and surprisingly nasty failure mode. If a CDN updates a file (a patch release, a minified build change, even whitespace) and your integrity hash is not updated in lockstep, the browser downloads the file, computes its SHA hash, sees the mismatch, and refuses to execute the script or apply the stylesheet. The network fetch itself succeeds. A status-code-based check sees 200 OK. The user sees a broken page with a console error buried below the fold. A dependency monitor that verifies the SRI hash alongside the fetch catches this; most do not.
Real failures this category catches
These are not hypothetical scenarios. Every one of them is a documented incident that took a visible chunk of the internet offline, and every one of them was invisible to origin-only monitoring.
- June 8, 2021 — Fastly global outage. About one hour down, dragging Reddit, Amazon, NYT, Gov.uk, Twitch, and Stack Overflow with it. Fastly’s post-mortem confirms the pattern: origins were fine, Fastly edges were the problem.
- February 28, 2017 — AWS S3 us-east-1 outage. A typo in a debugging command removed more capacity than intended, and S3 was unavailable in us-east-1 for about four hours. Slack, Trello, Quora, Medium, and Imgur broke because they stored static JavaScript and CSS in S3. Their own application servers were healthy throughout.
- July 2, 2019 — Cloudflare WAF regex bug. A catastrophic-backtracking regex in a newly deployed WAF rule pinned every Cloudflare edge server’s CPU, returning HTTP 502 for every Cloudflare-fronted site for 27 minutes. Every origin behind Cloudflare was untouched.
In every case, origin-only monitoring reported everything as healthy, because nothing about the origins was actually broken.
What Upsonar does
Upsonar runs the HTML parser described above, plus a similar pass over CSS files for web-font and @import references, on every monitoring cycle. Every discovered dependency on a different origin than yours is fetched from all 9 monitoring regions in parallel. Anything that returns a non-success status, fails DNS or TLS, or times out is surfaced on the check result with the specific URL, the failure mode, and the list of regions that observed it. The same approach catches SRI hash mismatches for any <script> or <link> that declares an integrity attribute.
External dependency checks are included on the free plan. No credit card.