There is a category of outage that almost no uptime monitor reports, and it is the one that costs the most.
The server is up. It accepts the connection, completes the TLS handshake, and returns HTTP 200 OK with a response body. Every box a standard uptime check looks at is ticked. The dashboard is green, no alert fires, and nobody is paged.
And the page is blank. Or it is an error message rendered inside your own template. Or it is last week's content served from a cache that never expired. Or the API returns a beautifully formed JSON object with an empty array where your products should be.
This is silent failure, and it is worse than a hard outage for one specific reason: a hard outage tells you immediately, and this one does not tell you at all. It ends when a customer finally emails you, which — depending on your timezone and their patience — can be hours.
Why status-code monitoring is structurally blind to this
A conventional uptime check asks one question: did the server respond, and was the status code below 400?
That question is answered by the web server. It is not answered by your application, your database, your CDN's cache, or the deploy that half-finished twenty minutes ago. Any of those can be comprehensively broken while nginx cheerfully returns 200 for a page containing an apology.
The gap exists because a status code describes the HTTP transaction, not the content of the response. Those are different things, and they come apart in exactly the situations that matter:
An application error caught by a global handler. Your framework catches the exception, renders a friendly "Something went wrong" page, and returns 200 because it successfully rendered something. From the outside this is indistinguishable from a working page.
A half-finished deploy. New HTML shipped, the API it calls did not. The shell renders, every data region is empty, status is 200.
A CDN serving a stale or empty cache entry. The origin may be down entirely; the edge is returning something, quickly, with a 200.
A database that is up but returning nothing. Connection pool exhausted, a migration mid-flight, permissions changed. Every query returns empty, every template renders with no data, everything is 200.
A soft 404. The page for a deleted product returns your normal layout with "not found" in the middle — and status 200, because whoever wrote the route forgot to set the code.
None of these are exotic. Together they are a large share of real incidents, and a status-code monitor is green through all of them.
Why it is the most expensive failure mode
Cost of an outage is roughly duration times traffic. Silent failures win on duration by an enormous margin.
A hard outage is detected in a minute or two by any monitor you have. Someone is paged, the clock starts. Silent failures have no clock — detection depends on a human noticing and bothering to tell you, and the median customer does not tell you. They leave.
There is a second cost that is easy to miss. During a silent failure your systems continue to look healthy, so you keep spending. Ad campaigns keep bidding, emails keep sending, onboarding keeps running — all funnelling people to a page that does not work. A hard outage at least fails fast enough that traffic bounces off a connection error and, often, is retried later. A blank page consumes the visit entirely.
And the reputational shape differs. "The site was down" is understood by everyone. "The site loaded but the checkout was empty and I could not tell if my order went through" is the kind of experience that ends a customer relationship, because it reads as broken rather than as briefly unavailable.
Four checks that actually catch it
Ordered by how much work they are to set up, cheapest first. Any one of them beats status-code-only monitoring; two of them make you fairly hard to surprise.
1. Keyword assertion. Pick a string that only appears when the page genuinely worked — a product name, a heading rendered from the database, the label on the primary button — and fail the check when it is missing. This is a single field in most monitoring tools and it catches blank pages, error shells, and empty templates immediately.
Choose the keyword carefully. It must come from real data, not your static layout. "Sign in" is in your header on every page including the error one. A product title rendered from the database only appears when the database answered.
2. Status-code assertion beyond "below 400". If your endpoint should return exactly 200, say so, and treat 204, 301, and 403 as failures. This catches soft-redirect and permission regressions that a "less than 400" rule waves through.
3. JSON assertions for APIs. For a JSON endpoint, assert on the shape rather than the status: a JSONPath expression that requires "status" to equal "ok", or an array to be non-empty. A 200 carrying {"items": []} is the API equivalent of a blank page, and only a body assertion sees it.
4. Response-size baselines. This is the one that requires no configuration at all, and it catches things you did not think to assert on. A working page has a fairly stable byte size. When it suddenly collapses to a fraction of normal, something is not rendering.
How the size baseline works, and where it fails
Since we run this one automatically, it is worth being specific about the mechanism and honest about its limits.
Every successful check records the response body size for that domain and maintains a running mean. Once a service has at least ten healthy observations — before that it has no baseline and we say so rather than guessing — each new response is compared against the mean. Bodies below 30% of it, or above 300% of it, are flagged as drift.
The 300% ceiling surprises people, because a bigger page sounds harmless. In practice error pages are frequently verbose: stack traces, debug output, and default server error pages are often much larger than the real page they replaced. Drift in either direction is a signal.
We only record observations we are confident are healthy — successful status, body above a minimum floor — so a broken response cannot quietly pull the baseline down toward itself and normalise the failure. That is the failure mode a naive rolling average has, and it is why the recording is gated.
What this does not do:
It does not detect wrong content of the same size. Swap one paragraph for another of similar length and the byte count barely moves. Size is a proxy for structure, not a checksum of meaning.
It produces false positives on legitimate change. A redesign, a new banner, or a genuinely different page will trip it. This is why the surface says the response "did not look like itself" and notes that a content change can also trigger it, rather than declaring an outage. A statistical signal presented as a verdict is worse than no signal.
And it needs history. A brand-new monitor has nothing to compare against, and a page whose size legitimately varies a lot per request is a poor fit. Keyword and JSON assertions handle those cases better, which is why the right answer is usually a baseline plus one explicit assertion, not either alone.
What to assert on, concretely
A short, opinionated list for the pages that matter most.
Homepage — a keyword from dynamic content, not the nav. If your homepage shows live data, assert on a label that only renders when the data arrived.
Product or listing pages — assert that at least one item rendered. The characteristic silent failure here is a perfect layout containing nothing.
Login — assert on the form, and be aware that a 200 with a form is not proof that authentication works. Only a multi-step check tests that.
Checkout — the most valuable and the most commonly under-monitored. Assert on the price or total, which only renders when the pricing service answered.
JSON APIs — assert on a field, not the status. "status" equals "ok", or an array is non-empty.
Status or health endpoints — beware. A health endpoint that returns {"status":"ok"} without checking its own dependencies is theatre; it will report healthy through a total database outage. If you monitor a health endpoint, make sure it actually checks something.
The uncomfortable question worth asking
If a silent failure started right now on your most important page, how would you find out — and how long would it take?
For most sites the honest answer is "a customer would tell us, eventually." That is not monitoring; it is hoping, with a dashboard attached.
The fix is not expensive. One keyword assertion on one important page, taking about a minute to configure, converts your most likely undetected outage into one you get told about on the next check. That is a better return than almost anything else you can do to your monitoring.
If you want to see this specific class of failure surfaced rather than buried, our checks record it as content drift and show it on the monitor alongside the uptime figure — including the honest caveat that a redesign can trigger it too. You can read the full detection ladder in <a href="/blog/how-websitedown-detects-an-outage">how detection works</a>, or set up a keyword or JSON assertion on your own site from <a href="/pricing">any plan, including the free one</a>.
The short version
HTTP 200 means the server replied. It does not mean the page works, and no status code ever will.
The failures that survive longest are the ones your monitor calls healthy.
One keyword assertion — on a string that only appears when the page genuinely rendered — catches most of them, and takes a minute.
For APIs, assert on the body. A 200 with an empty array is a blank page wearing a different hat.
And if you monitor a health endpoint, check that the health endpoint checks something.