The Page Arrived Before the Script It Needed
The complaint was mine, and it was the vaguest kind a person can file: games don’t load on the first try. Reload, and they load. I have had that symptom a dozen times and every time it has been a cache.
It was not a cache. The two requests were answered by two different versions of the site, and the second one happened to be a version that worked.
What was serving me
The thing serving me is small: a library of the old console games I own, running on a machine at home, for me. One process. It answers an API over the library, and it serves a directory of static files: a page, its script, its stylesheet, the fonts.
That directory was public/ inside the checkout, and the server served it straight from there. It is the first thing anyone writes, and it had been fine for months.
A directory is not a version
A directory is a place. What is in it is whatever was written there most recently, by anyone, for any reason, finished or not.
That checkout is a live working tree. I edit it, and so do the agents I leave running against it, sometimes concurrently. When a redesign lands, files change underneath the running server one save at a time, and the server serves each of those instants faithfully, because from where it sits there are no instants. There are only files, and it reads them when asked.
So one afternoon a new index.html reached my iPad six minutes before the app.js it had been written against. Neither file was broken. The pair had simply never existed together anywhere in the world except on my iPad, for six minutes, in a version of the site that was in no commit and never would be.
I reloaded. It worked. That was the entire bug report: games don’t load on the first try.
The other half had the opposite problem
Earlier that morning I had fixed the mirror image of it, in the same process, without noticing they were the same bug.
The API half is a long-running server, and it had no reload watcher registered at all. It kept serving the code it had booted with, across every commit, indefinitely. Pointed at it, the reload tool’s dry run reported it running three files behind.
Put the two halves next to each other. The static half was live to the keystroke. The code half was frozen at whatever it had started with, whenever that was. One service, one process, two opposite convictions about what day it was.
The collision is what put it in front of me. The page had been updated to expect a paged response from the library API. The running API still answered with a plain array, because it was still an older API. The shelf rendered empty. Fresh page, stale server, same request, same machine, no cache anywhere in the story.
The thing that was missing was a decision
I had never decided what version of that site was live. So every part of it decided independently, and they decided differently. Nothing was misconfigured. There was no configuration to get wrong. The concept was absent, and absent concepts never show up in a diff.
Naming it took about four lines. The server now runs git archive HEAD public, unpacks that into a directory of its own, and serves the export. The version of the site is a commit. Every file moves together or nothing moves. A half-written file in my tree is not served, because the tree is not what is served. Uncommitted interface work still needs a live view, so an environment variable switches a test instance back to the tree, which is the one case that actually wants a directory.
Then it failed the other way, eight minutes later
The redesign landed, and the play pages started 404ing on the theme and the font they had themselves just committed.
The thing that restarts a service after a commit watches src/. The redesign was almost entirely public/. So a commit containing every served file, complete and consistent and correct, was never picked up, because the export happens at startup and startup was not going to happen again. In under ten minutes I had gone from serving files that existed in no commit to refusing to serve files that existed in the newest one.
The second version polls HEAD, re-exports within seconds of any commit, swaps the static handler over, and removes the previous export; a stream already reading the old one finishes out of it. A commit that touches only the page is served, byte for byte, about six seconds later, with no restart at all.
What the fix bought
Once a version is a thing, you can ask when it takes effect, and the answer is allowed to be conditional.
The code half still needs a real restart, and a restart drops whatever connection an in-progress game is holding, which is how somebody loses an autosave. So that restart now waits while a game is being played. I could not have written that sentence a day earlier, and not because the logic was difficult. “When does the new version go live” was not a question the system was capable of being asked. There was no new version. There were files, and there was whoever got there first.
The two halves can still disagree. A page and an API are different artifacts and they ship on different clocks. What I have now is that each of them knows which version it is, which turns a disagreement into something a log can show me. What I do not have any more is a version of my own site that existed for six minutes, on one iPad, and nowhere else.