A web app for online sellers was built the way most modern single-page apps are: code split into chunks, routes loading lazily, an error boundary per screen, static hosting serving the HTML shell no-cache and the hashed assets immutable. A new build deletes the old chunks, which then 404, so any tab still on the old build gets a failed import on its next lazy route.
For that case the app had the common guard: when a dynamic import fails, reload the page once. If it fails again within 60 seconds, treat it as a real error and show the error card. A reasonable guard, and on most days it worked.
Key facts
- The app reloaded once on a failed chunk import, then refused to reload again for 60 seconds.
- One day had 24 production deploys. Deploy N+1 going live inside the 60-second window made the guard show the error card.
- Over 7 days, 41 users hit the failed-chunk reload signature within 15 minutes of a deploy; 19 of those bursts were on the 24-deploy day.
- About 170 more were silent auto-reloads nobody noticed.
- 38 of the 41 recovered on their own.
- Most hits were on the two busiest workflow pages. No client error logging existed, so this was measured from product analytics.
What happened
The guard's quiet assumption: at most one deploy happens between a tab's reload and its next lazy import. Break it, and a recoverable situation becomes a dead page.
Deploy N goes live; the old chunks 404. A tab open since before the deploy tries a lazy route, the import fails, and the guard reloads the tab. The reload lands on build N, and the 60-second cooldown starts: it just reloaded, so it will not reload again for a while.
Then deploy N+1 goes live inside that window. Build N's chunks are now the deleted ones. The seller clicks into the next screen, that lazy import 404s too, and the guard checks its clock: it reloaded seconds ago. It declines, treats the failure as a real error, and shows the error card. The seller sees a dead page.
One deploy per reload is a safe bet when you ship weekly. At 24 production deploys in a day, it is a bet you lose.
The sequence, step by step
How we measured it
No client error logging existed, so nothing reported this directly; the measurement came from product analytics: reload patterns, pageview and pageleave sequences that look like "open page, import fails, reload, fail again, leave", lined up against deploy times.
Over 7 days, 41 users showed the failed-chunk reload signature within 15 minutes of a deploy; 19 of those bursts came on the single 24-deploy day, most on the two busiest workflow pages, the screens with the most lazy routes. About 170 more reloads near deploys were silent auto-reloads: the guard's first reload, which worked, and nobody noticed. Of the 41 who hit the error card, 38 recovered on their own, by coming back later.
One check made it concrete: a known incident, reported by a seller, landed 8 minutes after a deploy. That is what you expect when a long-open tab finally wanders onto a deleted chunk.
An honest note on the method: some reloads near a deploy are chance; people reload pages all day. The signal is the cluster, not any single reload: the bursts concentrated on high-deploy days and on the pages with the most lazy routes.
The second trap: the route that could not cold-load
Fixing the guard exposed a second problem. The reload sometimes landed on a route that only worked via in-app navigation: it needed router state passed by the previous screen, so a cold load of that URL crashed.
A lazy route is a page people arrive at mid-session, but anything that can be reloaded, restored or opened from a link must rebuild its state from the URL and the server. The rule: any route you might reload into must survive a cold load. Check yours by opening each lazy route's URL in a fresh tab.
The fix
The guard's question is wrong. "Did I fail recently?" is about the tab's history. "Is there a newer build?" is about the world. Answer it by checking what is deployed.
When a reload is declined, fetch the deployed index.html; the shell is no-cache, so what comes back is the current build. Read its entry script hash and compare with the one the running page captured at boot from its own script tag. Different hash: a newer build exists, so reload with a cache-buster. Same hash: the deployed build is the running build, the chunk 404 is genuinely your bug, so show the error card and log it.
on importFailed(chunk):
if not reloadedRecently(60s): reload()
shell = fetch("/index.html", cache: "no-store")
deployed = entryScriptHash(shell)
if deployed != runningEntryHash:
location.replace(currentUrl + "?v=" + deployed)
else:
showErrorCard() # our bug, not a stale build
Five lines, and a failed import leads to a check, not a coin flip between "reload again" and "give up".
The rule
- Ask "is there a newer build?", not "did I fail recently?" The cooldown tells you about the tab; the hash tells you about the world.
- Every lazily loaded route must survive a cold reload. Rebuild state from the URL and the server, not from what the previous page passed in memory.
- Turn on client exception capture before you need it. We reconstructed this from analytics; one unhandled-error event would have shown it in a day.
- Deploy frequency is an input to your client's error handling. Guards tuned for weekly deploys misbehave at 24 a day.
Common questions
Why not reload on every failed import?
If the chunk is genuinely broken, reloading on every failure makes a reload loop: the page fails, reloads, fails again, forever. The hash check ends the loop. When the deployed hash matches the running one, the failure is a real bug, so you show the error card and log it instead of reloading.
Does the hash check work when the HTML is cached?
Only if the host serves the HTML shell with no-cache and the hashed assets as immutable. That is the standard setup for code-split apps, and it is exactly what makes the check reliable: fetching the shell always returns the current build, so the entry script hash tells you what is deployed right now.
How do I know it is a stale build and not my bug?
Compare the entry script hash in the deployed index.html with the one your page captured at boot. Different hashes mean the tab is running an old build and the chunk 404 is expected: reload with a cache-buster. Same hash means the deployed build is the running build, so the failure is genuinely your bug: show the error card and log it.
Melta is free; join the waitlist.