Engineering noteResearch & Engineering
Part of: React Router 8
Old Tabs, New Workers: A Guarded Reload
We traced a crash to old code reading new loader data, then found our tests expecting a failure report from a reload that worked.
The programme
On this page
When we deploy a new Worker, we leave old code running in open tabs. On 15 September 2026 we traced a crash affecting two people to a loader field we had renamed: we were sending new data to old components that still read the old field. In our React Router 8 app on Cloudflare Workers, we now compare the bundle’s release with a header on data responses and request a guarded reload when they differ. Then we found that some of our reports of a failed recovery came from documents we were already replacing. In those, the reload had worked. We had even written that behaviour into our tests.
The app serves TEEI Language and TEEI Mentorship. How TEEI Runs Its Platforms on Cloudflare Workers describes the Workers it runs on and how often we deploy them.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
What a tab keeps, and what changes under it
When someone opens a page, we load the document once, and with it the client bundle, the route manifest and the route modules the tab has visited so far. As the person moves on, we fetch what the tab lacks from the Worker version live now: loader and action data through single fetch, as a request to the same path with .data added (/ becomes /_.data, and resource routes are addressed the same way); route modules, as dynamic imports of hashed files from the tab’s own build; and route patches, through lazy route discovery, the React Router 8 default, which our production builds use.
So the tab runs release A while the answers come from release B, and three things can disagree:
- A file is gone. The Vite build guide warns: “When a new deployment occurs, the hosting service may delete the assets from previous deployments.” We have not measured whether our Workers still serve the previous build’s hashed files after a deploy, but on 28 September 2026 we recorded a dynamic import from the previous build failing three seconds after a deploy.
- The route map is out of date, which we recognise by React Router’s manifest mismatch error.
- The data has a new shape. This was the 15 September crash, and it is the hardest of the three, because it surfaces as a plain
TypeErrorthat looks exactly like a bug.
Cloudflare names the server-side form of this for gradual deployments: “clients and services can end up interacting with more than one version in ways that produce errors or inconsistent behavior. This is called version skew.” An open tab gets version skew from an ordinary deploy, because it carries the old version with it. These error shapes do not by themselves establish skew: on 1 August 2026 we recorded five Safari events in which the current release returned an undefined lazy route module.
Comparing releases on data responses
A TypeError in our own component is indistinguishable from a bug, so we will not reload on the error alone. We have to see the disagreement on the request that carries the data, before the old component renders it.
So we add one header to every loader and action response: the release that answered, a short commit identifier set at build time. The client bundle carries the same commit as a build-time constant, so the tab can compare without a request of its own. On the server, the release also marks an unclean working tree with -dirty, which the browser cannot know, so we strip the suffix on both sides and count the value unknown as no release at all rather than a different one. We compare normalised commit identities. Builds from the same commit can therefore compare equal even when their working trees differ.
We would have preferred a router hook. In framework mode we render HydratedRouter, so there is no createBrowserRouter call to give a dataStrategy to, and in React Router 8.3.0, we got status, errors and navigation metadata from client instrumentation, but no response headers (we checked on 15 September 2026). So we wrap window.fetch in the client entry, before hydration. In the wrapper, we read one header and return the original response; we never read, clone or change the body, and if observation throws, we still return the fetch result. We decide in a pure function; we handle storage, navigation and reporting in the wrapper:
Adapted for publication.
export function decideStaleRelease(input: {
clientRelease: string | null | undefined;
serverRelease: string | null | undefined;
requestUrl: string;
currentUrl: string;
hasReloaded: (guardKey: string) => boolean;
}): StaleReleaseDecision {
const serverRelease = normaliseRelease(input.serverRelease);
if (!serverRelease) return { action: 'ignore', reason: 'no-server-release' };
if (!isDataRequest(input.requestUrl, input.currentUrl)) {
return { action: 'ignore', reason: 'not-a-data-request' };
}
const clientRelease = normaliseRelease(input.clientRelease);
if (!clientRelease) return { action: 'ignore', reason: 'no-client-release' };
if (clientRelease === serverRelease) return { action: 'ignore', reason: 'same-release' };
const guardKey = staleReleaseGuardKey(clientRelease, serverRelease);
if (input.hasReloaded(guardKey)) {
return { action: 'report', guardKey, tag: STALE_RELEASE_TAG, clientRelease, serverRelease };
}
return {
action: 'reload',
guardKey,
url: reloadUrl(input.currentUrl),
clientRelease,
serverRelease,
};
}
We compare releases only on same-origin .data requests; a missing header is never evidence of skew.
Reload the page you are on
Our first version, on 15 September 2026, sent the tab to an address worked out from the request it had just seen. We corrected it the same day. Resource routes are .data requests too, and our signed-in shell polls one for the unread message count on every page, so the first poll after a deploy would have navigated a person onto a page of raw JSON. We now reload the current document, with its fragment stripped, because location.assign() to the same address with a #fragment is a same-document navigation that loads nothing. The person loses their in-page position once, and any click in flight.
Once per pair of releases
We key the guard on the ordered pair of client and server release, kept in sessionStorage: healing A to B is a different event from B to A. Before navigating, we write the key and read it back. We treat a tab with no usable storage as one that has already reloaded, and report instead, because a reload loop is worse than a stale tab.
The guard has no time window, with one exception. A page restored from the back-forward cache is an older document of the same tab, whose pair a newer document has already spent. When both its performance.timeOrigin and the guard’s time are known and the document is older than the guard, we reload it once more; if either time is unknown, we report. If the tab later meets another new release, that is a new pair and costs one more reload.
The reload that worked, reported as a failure
When a mismatch survived its reload, we reported it with the two releases and, when the guard could be read, how long ago the reload was spent. We add no request path to that payload.
By 24 September 2026 that report, “client release still differs from the server after one reload”, reached about 117 people a week, according to the commit that changed it that day. The investigation behind the commit looked at the 15 most recent production events. In all 15, the reporting document had been served before the deploy it disagreed with, and in five the same person already had a page on the server’s release within about a second.
In those five, the reload had worked, and the report came from the page on its way out. location.assign() is asynchronous: the old document keeps running while the new one is fetched. When we handled its next .data response, we found the guard we had just written and reported a persistent mismatch. We had pinned that behaviour as intended in our own test suite, because our fake assign never unloaded the page. Five of the 15 inspected events were confirmed to come from a document being replaced successfully. The review does not establish what share of all affected users had recovered.
So since 24 September 2026 we treat the reload as a handover between two documents:
- Once a document has called
assign(), we make no further reload decision or persisted-mismatch report from its later data responses. Since 30 September 2026 we still record the latest known server release from its data responses first, so an old component that throws on that data before the replacement completes can still be classified. - If a document is still alive 20 seconds after
assign(), we report it, because the replacement we asked for has not completed within our budget. Twenty seconds is generous: a slow network keeps the old document alive for seconds. We clear the timer on apageshowevent withpersistedset, the sign of a document back from the back-forward cache. - An older document restored from the back-forward cache reloads once more, under the conditions above, instead of reporting.
The new tests, run against the previous version of the file, failed five of six. The old test had not checked that the reload worked; it had checked that the departing page complained.
Since 30 September 2026, when a mismatch persists while the document and bundle name the same release and only a data response names another, we send the report as a warning. We interpret this as server disagreement during rollout.
The errors we still recognise
We compare releases before React Router reads the body, but the old component can still fail while the replacement loads, and other casualties of a deploy only show up as errors. For those we recognise the error itself.
| Shape | What the browser or React Router reports | First handled |
|---|---|---|
| Chunk load failure | Vite’s vite:preloadError; at a boundary, Failed to fetch (Chrome) or Load failed (Safari) | 12 July 2026 |
| Undefined route module | Cannot read properties of undefined (reading 'links') (Chrome), undefined is not an object (evaluating 'o.links') (Safari) | 13 July 2026 |
| Undecodable data stream | Unable to decode turbo-stream response | 16 July 2026 |
| DOM insertion desync | NotFoundError from insertBefore or removeChild | 1 August 2026 |
| Route result mismatch | No result found for routeId "…" | 9 August 2026 |
| Route discovery mismatch | Unable to discover routes due to manifest version mismatch. | 28 September 2026 |
| Release skew | Any TypeError while the tab’s release and the server’s differ | 30 September 2026 |
In six of them we constrain the error type and the wording as narrowly as we can. They classify symptoms; they do not prove that an error came from the framework. DOM desync needs no release mismatch at all: browser translation or an extension can move a node React expects to find, and a reload rebuilds the tree from the server’s HTML, so it shares the guard. Release skew matches by circumstance instead of wording. We use the latest release we have seen on a data response, without tying it to the failing request or component. We gain context without proving the cause, and we give that remembered release no expiry.
One trap is worth knowing if you handle Vite’s preload error yourself. When we prevent the preload error from a rejected import, we receive undefined. If we then read .default, we raise a second error. A prevented error from preloading one of the import’s dependencies, such as its stylesheet, is different: the import goes on and can succeed.
One guarded reload per minute
Every framework shape goes through one function. With session storage, we allow one reload per minute per tab; if storage is blocked, we keep the same window on the current history entry (since 22 August 2026), and if neither works, we do not reload. Before reloading, we record the reason. If we do not reload, we return the outcome, and at a route error boundary a refused reload falls through to the normal path: the person sees the error screen, and we report the error with its shape and recovery outcome. A comment in our code says of the undecodable data stream: “A persistent server fault re-renders the boundary after one reload (the guard blocks loops), so nothing real is masked.” That holds for a fault that persists: after one reload, it reaches the error screen and our reports. A real error can still cost one reload first, and once the minute has passed it can trigger another. The guard limits repetition; it does not prove that we classified the cause correctly. We report a network failure that repeats within the minute at low priority and show it as a connection problem, a reading of the error’s shape, not an established cause.
The old document’s analytics queue does not survive a reload, so we write the shape down first, and since 30 July 2026 the next document sends it as a reload event after hydration. That event shows that a document hydrated and consumed the marker. It does not show that the interaction the person started succeeded.
With the release guard above, we count pairs of releases instead of minutes.
React Router’s own version check
The release header is ours. React Router has a version check of its own, and a tab across a deploy can meet both. For React Router’s manifest version, we use its fingerprint of the client entry and route manifest; for our release comparison, we use the build commit. We compile the commit into the client entry, so a new release changes the manifest version too. We compare releases on every data response, while the manifest check runs only when route discovery is needed.
When the versions differ, the /__manifest request receives 204 with an X-Remix-Reload-Document header, and we rely on React Router 8.4.0’s own handling, which we read in its installed source: during a navigation, we get a document load of the page the tab was heading for, and during a fetcher call, a load of the current page. During background discovery, we get a warning without reloading. We get a document load when a later navigation or fetcher call needs an undiscovered route. If that document has not replaced the page within five seconds, or the same manifest version has already reloaded once, we get React Router’s error Unable to discover routes due to manifest version mismatch. Our fix of 28 September 2026 records that this had reached six people, each on a bundle from before a deploy, as a crash over the page. Since then we match that exact sentence and request one guarded reload of the current document under the per-minute guard.
A manifest request can also hang. On 15 September 2026, with React Router 8.3.0, we saw a fetcher action for an undiscovered path enter its submitting state and wait on a /__manifest fetch with no time limit, while an interface rendering from fetcher.json showed a write as done before its POST had left the browser. We now share one request per URL and allow up to three attempts when the transport or the body read fails, each with a budget covering both; our first version stopped its clock at the headers. We buffer the manifest, clone it per caller and keep the body null for the 204, so we retain React Router’s handling of its reload header. A caller whose signal aborts stops waiting while the shared request runs on. For loader data we return the original response unread.
What the tests hold
On 15 October 2026, 168 tests in six files passed. They run against doubles for window, fetch and sessionStorage, not a real browser, and pin React Router’s manifest call, fetch(url, { signal }), since our wrapper ignores a Request. The properties that matter: each recogniser accepts the wording seen in production and leaves near misses such as Failed to fetch profile visible; an unread-count poll after a deploy never shows a person raw JSON; the departing document sends no persisted-mismatch report from later data responses; a document still disagreeing after its reload is reported once for that release pair and not reloaded for it again; and a tab that cannot remember a reload never tries one. We also drive React Router’s installed discovery function: without our wrapper a stalled manifest never settles and an aborted body read fails the navigation; with it, a fresh attempt succeeds. In 8.4.0, the added timer starts after the manifest answers and bounds the document replacement; the fetch itself has only our limit. The 24 September commit records a local browser check we did not repeat.
When this is the wrong pattern
A reload throws away what the tab held: a half-typed message, a scroll position, a click in flight. That is why the release comparison acts only on data requests and only once per pair. If your app holds long-lived client state, a reload on every skew will hurt more than a stale tab.
A release mismatch classifies; it does not prove. We treat any TypeError as release skew while the releases differ, so a real bug that ships in the same deploy can be reloaded once before it shows. If it recurs inside that minute, we show and report it; a later recurrence can trigger another guarded reload.
Try the cheaper layers first. Vite’s guide advises: “make sure to set Cache-Control: no-cache on the HTML file, otherwise the old assets will be still referenced.” For gradual rollouts, Cloudflare offers version affinity. Neither updates a document that is already open: to move a tab that loaded an hour ago onto a new release, we need a document navigation, or client code in the tab that starts one. TEEI Language and TEEI Mentorship run as two Workers from one codebase, as Two Workers, One React Router 8 App: Decide at the Boundary explains, so a tab compares itself only with the Worker on its own origin.
What we have not measured. We have no before and after measurement of stale-tab errors, so we cannot separate the effect of the release comparison from the older shape fixes, and we have not counted how many reloads each shape caused or how often one saved a session. The September figures come from commit messages written on the day, not from a measurement repeated now. We quote our code as it stood on 15 October 2026 and date each measurement.
Verify this
Reproduce the skew in your own project. Build two versions of a React Router app, have your server or Worker set a release header on its responses, open the first build in a tab, deploy the second, and navigate in the old tab with the network panel open. You will see the old bundle’s .data request answered by the new release. The header alone heals nothing: the observer and the reload guard are code you write, and the decideStaleRelease excerpt above is a starting point. To see React Router’s own branch, keep lazy route discovery on, deploy a build that changes the route tree while a tab on the old build is open, and follow a link to a route that tab has not discovered: the /__manifest request answers 204 with X-Remix-Reload-Document.
Frequently asked questions
Why does a React Router app crash in a tab that was open during a deploy?
The tab keeps the bundle, route manifest and components it loaded, while its data, route patches and code come from the Worker that is live now. A missing file, a manifest the server no longer agrees with, or data in a new shape can each end in an error screen.
How do you stop a reload loop?
With two guards. For a release mismatch on a data response, we reload once per pair of client and server release in a tab, and report after that; an older page restored from the back-forward cache may reload once more. For recognised framework errors, we allow one reload per minute per tab, or per history entry when session storage is blocked; a refused reload at a route boundary shows the normal error.
Why does the old page keep running after location.assign()?
Navigation is asynchronous: the old document keeps handling responses while the new one loads. Once it has asked for its replacement, we stop it from deciding or reporting, and report only if it is still alive 20 seconds later.