The Answer Was HTML

Home Movies broke on a phone this week in the most unhelpful way a web app can break: it loaded, and it looked like nothing. No layout, no styling, raw markup sitting in a browser tab. No error in sight. The page just quietly declined to look like an app.

What the browser was actually asking for

The server that hosts the client bundle is a single Express app: serve the built static files, and for anything that doesn’t match a real file, fall through to index.html so the client-side router can handle it. That fallback is the standard shape for any single-page app: it’s what lets a browser load /library/some-show/episode-4 directly and still get the app instead of a 404, because the app itself decides what that URL means, not the server.

The phone in question had an old copy of index.html sitting in its cache from before the last deploy. That old page asked for a CSS bundle by its hashed filename: the usual cache-busting trick, where a new build gets a new filename so browsers never serve stale styles by accident. Except the build that produced that filename was gone. The bundle didn’t exist anymore. And the server’s catch-all doesn’t distinguish “you asked for a page my router should handle” from “you asked for a file that used to exist and doesn’t.” It treats both as the same case, and answers both with index.html.

So a request for a stylesheet got a 200 and a document back. Not the stylesheet. The homepage.

Why nothing complained

I added a temporary access log (nothing more than one line logging method, path, and status code on every response) specifically to see what the phone was actually requesting, because nothing in the app’s own error handling had fired. That log is what surfaced the mismatch: a request to /assets/index-<hash>.css coming back 200, over and over, on every reload.

The reason it stayed invisible without that log is that Safari doesn’t treat “I asked for CSS and got HTML” as an error worth surfacing. It just declines to apply the response as a stylesheet and moves on: no console warning loud enough to notice at a glance, no failed network request in red, nothing that says “this didn’t work.” The same is true of a <script> tag pointed at a dead bundle: get HTML back instead of JavaScript, and the browser mostly just doesn’t run it. Both failures are graceful in exactly the way that makes them hard to find. A loud failure gets fixed fast because it’s impossible to ignore. A silent one ships.

The fix, and the assumption underneath it

The fix is four lines: before falling through to index.html, check whether the request path looks like /assets/ or /api/, and if so, answer with a real 404 instead. Those two prefixes are never real client-side routes (nothing the app’s router handles lives under them), so there’s no ambiguity to trade away. A missing asset now fails as a missing asset.

The catch-all’s original assumption was that anything unmatched must be a navigation the router should own. That’s true right up until a build gets replaced and something out there (a cache, a bookmark, a half-loaded tab) is still holding a reference to a file from the build before. At that point the same fallback that makes deep links work is the thing quietly handing back a homepage disguised as a stylesheet. The fix isn’t “stop using a catch-all.” It’s narrowing what the catch-all is allowed to catch, so a gap in the file system still looks like a gap, instead of looking like success.


← all writing