Deploying a static frontend looks like the one deploy you cannot get wrong. The build produces a folder of files. You upload the folder to a bucket behind a CDN — in parallel, because there are hundreds of files and you are not a patient person — and when the last upload finishes, the new version is live. No servers to restart, no migrations. The deployment equivalent of handing someone a USB stick.
That is how a frontend I look after used to ship: glob the build output, push everything to storage in one pass, done. It is an appealing amount of deployment code to maintain.
The folder on disk is a complete release. A browser does not get the folder in one piece, and an already open tab may not ask for it at all.
Take a pinned tab. A single-page app can keep running long after the page was loaded. Someone opens the app on Monday, pins the tab, and three releases later that tab is still running Monday’s JavaScript against Thursday’s backend. Nothing on the screen says so. The app doesn’t look old. It quietly is.
Then there is the upload window. “Push everything in parallel” means files land in whatever order the network prefers. Our bundled JavaScript and CSS have fingerprinted names like index-8f3a91c2.js; when their contents change, their names change too. The new HTML references those names. So there can be a window in which the new page is already public while a chunk it needs is still uploading. A visitor who arrives inside that window gets the new page before its dependencies.
Ours came with a bonus. The web server fell back to index.html when an upstream file was missing, so client-side routes could survive a refresh. Helpful for routes, less helpful when the missing thing is a JavaScript file. The browser asks for a script, receives an HTML document with a confident 200 attached, and refuses to run it. Part of the fix was small and unglamorous: a missing file under /assets/ now returns an honest 404 instead of a page cosplaying as code. That makes the failure legible. It still leaves the upload order to fix.
Take the stale tab first.
The Page Learns Its Own Version
Said out loud, the problem has a simple shape: the page needs to know which version it is, and it needs a way to ask which version is current.
The shipped index.html carries its commit hash in place of a template placeholder, so the loaded page knows its own version. The other half is a tiny file published next to the app: COMMITHASH.txt, holding the release’s hash. The browser can fetch that file without reloading the application or disturbing the work inside it.
The client compares the two when it loads. If they match, it checks again a minute later. If they differ, it stops checking and shows a banner saying a new version is available, with a reload button.
A button, deliberately. The app could reload itself the instant it notices the mismatch, and everyone halfway through a long form would have opinions about that. A version bump is information, not an emergency. The person in the tab decides when to act on it; until they do, the banner sits there and waits.
The hash is a version marker, not an integrity check: it says “this page is build such-and-such” and nothing else. A mismatch tells us the page and the marker name different versions. It does not tell us which one is newer, or whether the files for the marker’s version have finished uploading. That is why detecting updates does not close the upload window.
Assets First, Index Last
I kept the parallel uploads, but put a boundary around what could run together. The new index must wait for the release files it references, so the upload now runs in three phases: assets, then COMMITHASH.txt, then index.html. Each phase finishes before the next starts.
The changed, fingerprinted bundles can go up first because the live index.html still references the old names. This upload does not delete the old files. Unchanged bundles keep their existing names, and the new ones wait in storage until a page asks for them. There is no reason to make those uploads take turns; they just need to finish before the index gets its turn.
The version marker goes second, and the reason is a small trap. Suppose it went last, after the index. A visitor loads the brand-new page, which promptly compares its own hash against the marker and reads the old one, because the marker is still on its way up. Mismatch. Banner. The freshest page in production is now telling its user to update. Since our page stops checking at the first mismatch, uploading the correct marker a moment later will not dismiss that banner. It stays until the page is reloaded. Publishing the marker first avoids creating that particular false alarm in the upload sequence.
That leaves index.html. Its name stays the same across releases, while the bundle names inside it change. Replacing it is the step that directs fresh page loads to the new build. By the time the uploader starts that replacement, it has finished uploading the release’s other files and its version marker.
This is still not an atomic deploy: CDN caches and overlapping releases need their own handling. Even within this sequence, an old tab can notice the new marker just before the index changes; an immediate reload can still fetch the old page. The useful guarantee is in the uploader: it no longer publishes a new index while it is still uploading that release’s dependencies.
Most of the work can still happen in parallel. The small file that sends browsers to all that work goes last.






