My travel site was completely non-functional for about four hours today and never once went down.
That is not wordplay. Every route returned HTTP 200 the entire time. Time to first byte was normal. The HTML came back full of content — property names, headings, structured data, the lot. An uptime monitor pointed at any page on the site would have recorded a flawless afternoon.
Nobody could book anything, because there was nothing to book.
What actually broke
The site's backend runs on a managed platform — a hosted wrapper around Postgres that bundles the database, authentication, file storage and serverless functions into one project. The workspace was on the free tier. Its allowance ran out and the project was paused.
There was no option to settle a balance and carry on. The dashboard offered exactly one action: upgrade to a $25/month plan. That is worth separating from the usual story about an operator forgetting to pay a bill, because it is a different failure — the free tier had been running production for nine months, and the notice that it would stop was the stopping itself.
The first thing I checked was DNS, and that told me how complete this was:
$ dig +short @1.1.1.1 <project>.supabase.co
$ dig +short @8.8.8.8 <project>.supabase.co
$ dig +short @1.1.1.1 supabase.co
76.76.21.21
Two independent resolvers, no answer. The parent domain resolved fine, so this was not a DNS outage — the subdomain had been withdrawn. NXDOMAIN, not a pause page.
One exhausted allowance took four subsystems with it at once: the database, every login, every uploaded image, and the edge function that records outbound clicks. They felt like separate concerns right up until they shared a fate.
What a visitor saw
Here is the part that has stayed with me.
The site is a React SPA with a prerender step: at build time, every route is rendered to static HTML with its data baked in. That HTML is what a crawler gets, and it is why curl looked reassuring:
$ curl -s https://<site>/hotels | grep -o "Patterson Inn"
Patterson Inn
The content was right there. So for a few minutes I believed the damage was limited to logged-in features.
Then I rendered the page in a real browser instead of reading its source. React hydrated, fired its query at a host that no longer existed, got nothing, and re-rendered the component with an empty array.
Found 0 verified stays.
Every filter read zero. The listings area showed skeleton placeholders. The page was beautifully styled, fully responsive, instantly loaded, and contained nothing whatsoever.
I checked the one thing that actually pays for the site:
$ curl -s https://<site>/hotels | grep -c "affiliate"
0
Not a single outbound booking link exists in the static HTML. Those URLs come from the database at render time. So there was no accidental fallback, no degraded-but-working state. Just a confident, empty shop.
Why nothing alerted me
I found out because I happened to open the platform dashboard for an unrelated reason.
Think about what a conventional check would have needed to notice. HTTP status? 200. Response time? Fine. Does the HTML contain expected text? Yes — the prerendered content was intact. Is the page non-empty? Very. Does the site load? Beautifully.
Every signal a normal monitor collects was healthy, because every one of them measures whether the server answered, not whether the answer meant anything. The failure lived entirely in the gap between those two questions.
That gap is not exotic. Any site that renders static shells and fills them from an API has it. The more thorough your prerendering, the wider it gets — because the static layer keeps looking healthy long after the dynamic layer has died.
The crawler got a better experience than the customer
There is a genuine consolation, and it is a strange one.
Googlebot does not execute JavaScript on the schedule a visitor's browser does. It was served the prerendered HTML — 623 routes of intact content, correct structured data, working internal links. Throughout the outage, the search engine's view of the site was perfectly healthy.
So the prerendering protected the index and abandoned the customer. For a nine-month-old domain still being assessed, that is the more expensive of the two to lose, and I would not have chosen differently. But it is worth being clear about what was protected and what was not, because "the prerendering saved us" is only half true and the other half was every booking that afternoon.
Resolving is not serving
One more thing, because it cost me the backup.
Before paying, I set a script running that polled DNS and would export everything the moment the host came back — the idea being that if the alert came at 3am, the data would already be on my disk.
It fired at 15:42. It captured nothing.
DNS came back roughly forty minutes before the service did. My readiness check probed the API root, got a 401, and treated that as life — a 401 is a valid HTTP response from a live server, after all. It then requested every table and received 521 for each one, which is the CDN saying it cannot reach the origin. The script dutifully reported sixteen tables as "not exported" and exited pleased with itself.
The fix is to probe something that only works when the thing actually works:
# before: any response means alive
code=$(curl -s -o /dev/null -w '%{http_code}' "$HOST/rest/v1/")
[ "$code" != "000" ] && break
# after: a real table, and only 200 counts
code=$(curl -s -o /dev/null -w '%{http_code}' \
"$HOST/rest/v1/hotels?select=name&limit=1" -H "apikey: $KEY")
[ "$code" = "200" ] && break
Same mistake as the monitoring, one layer down. I asked "did something answer?" when the question was "did the thing I need work?"
The re-run pulled 91MB: 220 countries, 143 dispensaries, 58 properties, and 563 storage objects, none of which failed.
The reframe
Uptime is a measurement of whether your server responds. For a static site those are the same thing, which is why the convention exists and why it goes unexamined. For a data-driven site they are different questions, and the difference is the entire business.
A 500 would have been better. A 500 pages an on-call rota, trips a monitor, sends an email. What I had instead was a site that passed every automated check while converting nothing, and would have kept doing it for as long as I did not personally look.
So the health check I actually needed was never about the server. It was about the data:
Fetch the listings endpoint. Assert the count is greater than zero.
Two lines. It would have caught this within a minute, and it is the only check in this entire incident that would have.
The corollary is a build I have not made yet: the prerender already pulls every listing at build time, so the client could fall back to that baked-in data when a live fetch fails, instead of rendering zeros. Stale listings still sell. Zero listings sell nothing, and look immaculate doing it.
Top comments (0)