An HTTP 200 Is Not Uptime: The Monitor Went Green While the Page Served an Error
The simplest uptime check is one line: fetch the URL, and if it returns 200, the site is up. It is the check almost every homegrown monitor starts with, and it is wrong in a specific, expensive way. A 200 means the server accepted the request and chose to answer. It says nothing about what it answered.
A web server that has lost its database will very often return a 200 — with a rendered "Something went wrong" page, because the error handler is the one part of the stack still working. A checkout page whose payment provider is down serves a 200 with a friendly apology where the pay button used to be. A JSON API returns 200 with an empty array, or 200 with {"error":"…"}, because whatever framework wraps it defaulted the status. A login form 302s to an unexpected host after an auth-provider outage, and the redirect itself is a perfectly healthy 302.
Every one of those is down to the person trying to use it, and up to a monitor that reads the status code and stops. The gap between "the server responded" and "the server responded correctly" is where a whole class of outages hides — the ones where your dashboard is green and your support inbox is not.
Three questions a status code cannot answer
To close that gap, an uptime probe has to be able to assert more than one thing, and each assertion is a configured question, not a global rule:
- Is the status the one I expect? Usually
200, but not always — a health endpoint might correctly return204 No Content, a paywalled URL401, a deliberately-gone page410. "Up" means the status I said to expect, not "any 2xx." - Does the body contain the text that proves the page actually rendered? The single most useful assertion. Pick a string that only appears when the page is genuinely working — the price on a product page, a form's submit label, a known record in an API response — and require it. A 200 error page will not contain your checkout button's text.
- Did the request land where it was supposed to? If a URL is meant to serve content directly, silently redirecting to a login screen or a marketing page is a failure the status code applauds. Asserting the final URL after the redirect hop catches an auth or session flow that has quietly started sending everyone somewhere else.
The rule that ties them together is the important part. The probe is up only when the status matches AND every configured assertion passes. A green result has to mean "responded, with the expected status, containing the expected content, at the expected URL" — not "responded."
up = statusMatches AND redirectOk !== false AND keywordFound !== false
Note what is not in that expression: an assertion you never configured cannot make the check fail. That !== false rather than === true is deliberate, and it is the second trap.
Why an unconfigured assertion must stay null, not fail closed
The instinct when you add a content assertion is to make it a boolean: found or not found. That instinct will page every existing monitor you have.
Most of your checks do not have a keyword configured. If "keyword not found" is false, and false gates up, then the day you ship the feature every check without a keyword flips to down — not because anything broke, but because "no keyword was required" and "the required keyword is missing" collapsed into the same value. Thousands of healthy assets page at once on the next sweep.
So a content assertion has three states, not two: true (required text present), false (required text absent — a real failure), and null (no text was required — say nothing). Only an explicit false gates up. null is inert. The same holds for the redirect-target assertion: configured and mismatched is a failure; not configured is null and cannot fail. This is the difference between a feature you can turn on for one asset without disturbing the other ten thousand, and a feature that re-classifies your entire fleet the moment it deploys.
The bug that bit us: computed, stored, labeled — and read by nothing
Here is the part I can tell you from our own incident ledger rather than a whiteboard.
We shipped the content assertion early. The probe computed keyword_found. It wrote keyword_found to the check row in the database. The pricing page sold the tier as "HTTP uptime & response-assertion monitoring." The app's asset form had a field labeled "Keyword that must appear in the response." A customer could type their checkout button's text into that field and hit save, and everything looked wired end to end.
The value was read by nothing. Not by the up calculation. Not by the classifier. Not by a single alert leg. It was computed on every probe, persisted forever, and consulted by no code path. A customer whose page started serving an HTTP 200 error page — the exact scenario the feature was sold to catch — got up: true, a green status page, and silence. Indefinitely. The assertion they configured was a decorative column.
This is the most insidious shape a monitoring bug takes, and we have hit it more than once in different subsystems: the signal is measured and recorded and never told to anybody. It passes every test that checks "is the value computed correctly," because the value is computed correctly. What no test asserted was that the value changed a verdict. The fix was one clause — make an explicit keyword_found === false gate up, exactly the way the redirect-target assertion always had — so that the two configured assertions finally behaved alike instead of one being load-bearing and the other being scenery.
The lesson generalizes past this one field: a value that does not change an output is not a feature, no matter how correctly it is calculated or how prominently the UI labels it. If you add an assertion, write the test that proves a failing assertion turns the check red — a control that would fail if you deleted the gating clause. Testing that the number is right is not testing that the number matters.
When the check is down, name which assertion failed
There is a corollary. Once "up" can be false for three different reasons — wrong status, missing content, wrong final URL — a bare "DOWN" is not enough to act on. Someone paged at 3 a.m. about a host that returned 200 needs to be told which assertion failed, or they will burn ten minutes proving the site loads fine in their browser before they realize the monitor meant "your checkout text is gone," not "your server is unreachable."
So a failing assertion should carry its own reason: keyword_missing: the response body does not contain the required text "…", or redirect_target_mismatch: landed on X, expected Y. The status code alone does not explain a verdict of down-at-200; the alert has to name the thing it is about.
Two honest timings, and no invented ones
While you have the response open, it is tempting to report a phase-by-phase waterfall — DNS, TCP, TLS, first byte, transfer. Be careful about where you run. On some serverless runtimes, fetch() exposes no phase breakdown at all; you get the start, the moment headers arrive, and the moment the body finishes, and that is it.
Two of those are honest and worth reporting:
- time-to-first-byte — headers received; the server started answering.
- total response time — the body was fully read.
A slow TTFB with a fast body points at the server thinking; a fast TTFB with a slow total points at a large or trickling payload. That distinction is real and useful. What is not honest is synthesizing a "DNS: 12ms / TLS: 40ms" breakdown from an API that never gave you those numbers. Report the two phases you actually measured and do not fabricate the ones you did not. A monitoring product's entire value is that its numbers are true.
What Merlonix ships
Merlonix's HTTP uptime check is a structured probe, not a status ping. You configure the method (GET/HEAD/POST/PUT/PATCH), optional request headers and body, the expected status code, an optional keyword that must appear in the response body, and an optional expected final URL after one redirect hop. The result is up only when the status matches and every assertion you configured passes; a failing assertion records the specific reason, so the alert names what broke. It reports TTFB and total response time — the two phases the runtime actually exposes — and nothing it did not measure. Requests are SSRF-guarded (resolve, reject private/link-local targets, then connect to the vetted address), so a check URL cannot be turned into a probe of your internal network.
And the decorative-column bug above was ours, on our own code, before an explicit failed-keyword gate was made load-bearing. The three-state assertion and the "up gates on every configured check" rule in this post are not a design proposal. They are the diff.
→ Related: A Port Check Has Three Answers, Not Two → Related: A Dead-Man's Switch That Pages Once and Goes Quiet Is Worse Than None → Related: Running a Monitoring SaaS on Cloudflare Workers + Supabase for Almost Nothing