I built myself a medication tracker. Single user, me, running on my own server, reachable only over my own private network. Not a health app in the App Store sense — no dose advice, no interactions, no clinical logic anywhere in it. It counts what I have, tells me what’s due, and yells at me before I run out. That’s the entire scope, on purpose, because the second a personal project starts pretending to be a doctor is the second it becomes a fucking liability instead of a tool.
I finished it. Deployed it. Started actually using it for real medications, which is the part that matters — a supply tracker that only ever gets tested with fake data hasn’t actually been tested for shit. And within days it started telling me it couldn’t reach the server. From my phone. From my desktop. Every device, same error, connection refused, like the whole thing had just died in the night.
The server had not died in the night. The server had never been healthier. I was staring at a goddamn ghost.
Guilty until proven guilty
First instinct, obviously: check the server. It was fine. Container healthy, every endpoint answering, curl from a completely different device on the network getting clean 200s back like nothing was wrong, because nothing fucking was.
Second instinct: check the network. Also fine. Nothing had changed there in weeks.
Third instinct, the one that should embarrass me a little: assume the phone was lying, restart it, uninstall the app, reinstall it from the home screen, watch it fail exactly the same way, immediately, with total confidence, like it had done this before.
It had done this before. It had done this every single time, because the thing I was reinstalling wasn’t actually broken — it was faithfully, perfectly doing exactly what a Progressive Web App is designed to do, and the design was the fucking problem.
The ghost was mine
Here’s the part that stung once I found it: a PWA doesn’t get installed from an app store with a name and a version number. It gets installed from a URL, and it stays bound to that exact origin forever. Not the app. The address. Reinstalling doesn’t fix a stale origin, it just re-commits to the same dead one, because as far as the phone’s concerned that origin is the app’s whole identity.
Months earlier, before there was anywhere real to deploy this thing, I’d stood up a quick throwaway address on my laptop just to test how the layout looked on an actual phone screen instead of squinting at a browser window pretending to be one. Totally reasonable thing to do. Tore it down the moment real deployment happened, also totally reasonable. Except at some point during that testing, I’d installed the damn thing to my home screen from that scratch address, and never thought about it again, like a dumbass.
So the icon on my phone was never talking to the server. It had never once talked to the server. It had been faithfully, patiently trying to reach a machine that stopped listening for that address weeks ago, and every single time it failed, it failed with an error that is byte-for-byte identical to “the server is down.” ERR_CONNECTION_REFUSED doesn’t editorialize. It doesn’t say “hey, this domain doesn’t exist anymore, you absolute muppet, might want to check that.” It just refuses, flatly, and leaves you to guess why.
Fixed by pointing the install at the actual live address and pinning that URL somewhere obnoxiously obvious so future-me doesn’t get to relive this shit.
What the silence was hiding
Here’s where it stopped being embarrassing and started being useful. The server had no access log. None. Which meant when this started, I had zero way to tell the difference between “the phone tried to connect and got rejected” and “the phone never tried at all.” Those are completely different failures with completely different fixes, and I couldn’t tell them apart because I’d never built the thing that would let me.
So I added one. And the very first thing it proved was that every single entry in it, for the entire time this had supposedly been “down,” was the container’s own healthcheck pinging itself every thirty seconds like clockwork. Nothing else. No failed request. No rejected connection. Not one single byte from my phone had ever arrived, because it was never being sent anywhere near this fucking machine in the first place.
That log — something I only built because I got burned — is also what turned up two real bugs hiding underneath the fake one, which is exactly what happens when you finally go looking properly instead of vibing it. The app’s reconnection logic was trusting the browser’s online event to know when the network came back, and that event is basically decorative on a phone — so a genuinely brief network drop could get treated as permanent, forever, until you manually intervened like an idiot. And separately, when the app did recover, it only bothered refreshing two of its three screens and left a stale “can’t reach server” panel sitting on the third, so even a fully working reconnect still looked broken if you happened to glance at the wrong tab.
Neither of those caused the outage. Both would have made a real outage worse, invisibly, and I only found them because a fake one forced me to build the tooling to actually see what was happening instead of guessing at it.
The pattern underneath all of it
I’d already built this thing around one rule, from the start, mostly out of paranoia: nothing in this app is allowed to be blank or silent by accident, because for a medication tracker, blank and silent both read as “nothing to worry about,” and that’s exactly the one lie this app is not allowed to tell, ever. An empty list has to mean the server confirmed there’s nothing due — never “the request hasn’t come back yet” wearing an empty list’s clothes like a coward.
This whole outage was that exact philosophy failing at a layer I hadn’t built it into yet. The client had no way to say “I am not even reaching the network you think I am,” so it said nothing, and nothing looks exactly like everything being fine, which looks exactly like everything being fucked, depending on which side of the silence you’re standing on. A dead request and a healthy one that just hasn’t finished look identical if you never bother asking which one you’re actually looking at.
Turns out the same rule that governs whether an empty dose list can be trusted also governs whether “can’t connect” means what you think it means. Silence is never neutral. It’s just a claim wearing a disguise, and the whole job is making sure the disguise never fucking fits.
