Once a browser has fetched an index.html served without Cache-Control (the header that says how long a response may be cached), it keeps using its own copy without going back to the server. That period is 10% of the time since index.html was last modified.
If you deploy during that period, reopening the site still runs the previous version's page. The files that page loads later are gone from the server, so when a user uses a feature the page hasn't loaded yet, the load fails and clicking the button does nothing. An error tracker (Sentry or similar) shows this line:
TypeError: Failed to fetch dynamically imported module: <chunk URL>
It means a JS file the page tried to load at runtime with import() could not be fetched. A chunk is a separate file the build splits some of the page's features into. It is loaded for the first time when the user clicks a button or similar. The error hits only users who use a feature the previous version hadn't loaded yet. Everyone else keeps running the previous version with no error.
On a site that deploys once a week, the previous version keeps running in users' browsers for up to about 17 hours after the deploy. Browsers that open the site for the first time after the deploy, or that reload, run the new version, so checking internally may not reproduce it. Users running the previous version don't get a fix even after you deploy it.
I reproduced this with a Vite app served by nginx with its default config, and measured how long the previous version runs and what each fix does.
What is happening
For responses without Cache-Control, the HTTP caching spec1 lets a cache guess how long it may use its copy without going back to the server, and gives 10% of the time since Last-Modified (when the file was last modified) as a typical value. Chromium uses exactly 10% of the difference between the time the response was sent (Date) and Last-Modified.2 Below, the difference between Date and Last-Modified at fetch time is called the time since modification.
Files with a hash (a string the build derives from a file's contents and puts in its name) get a new name whenever their contents change. Which version's files get loaded is decided only by the file names written in index.html, whose own name never changes. Without Cache-Control, the browser decides when to fetch that index.html again.
When build artifacts are baked into an image, Last-Modified is the build time. On a site that deploys once a week, a browser that opened the site just before the next deploy uses its own copy of index.html, without going to the server, for about 17 hours, 10% of 7 days.
1 2 3 4 5
| | | | |
server serves
[=====old=====][============new=============]
browser runs
[===========old===========][=new]
<- 7 days -><------- 16.8 hours ------>
The top row is the version the server serves. The bottom row is the version that runs when the browser that opened the site at 2 reopens it at each point in time. 1 is when the previous version was built (Last-Modified), 2 is when the user opened the site and fetched index.html (Date), 3 is the deploy, 4 is when the user reopened the site, and 5 is when 10% of the span from 1 to 2 has passed since 2. The span from 1 to 2 is compressed in the figure. If a deploy falls between 2 and 5, reopening the site still runs the previous version. After 5, index.html is fetched again and the new version runs.
Verification
I served a Vite app that loads a chunk when a button is clicked, using nginx with its default config. I opened the previous version, closed the browser, deployed by replacing the container, then relaunched the browser and opened the site again.3
| Time since modification | How long the previous version ran |
|---|---|
| 10 min | About 1 min |
| 100 min | About 10 min |
| 1000 min | About 100 min |
| 6 days | About 14 h |
Each was about 10% of the time since modification. Reopening the site in a new tab with the browser kept open gave the same result.4
In browsers that hadn't clicked the button on the first open, clicking it while the previous version was running produced the error. In browsers that had clicked it, no error appeared.
Reloading while the previous version was running fetched index.html again and switched to the new version.
Checking your own environment
The response headers on index.html tell you whether browsers keep using their own copy of it. The access log tells you whether any users ran the previous version and hit the error.
Response headers
Check the response headers on index.html with this command. Look at the same headers whether you serve from S3, a CDN, or a hosting service, or render the HTML on the server per request.
curl -sI https://app.example.com/ | grep -iE '^(cache-control|expires|last-modified):'
If the response has Last-Modified but neither Cache-Control nor Expires (the header that sets an expiry time), the previous version keeps running after a deploy for 10% of the time since modification.
Access log
From a browser that ran the previous version and hit the error, the new version's server receives only the following line and returns 404 (the status meaning the file doesn't exist). It is a request for the previous version's chunk, and the same browser sends no request for index.html.
"GET /assets/feature-DHyZGV0s.js HTTP/1.1" 404 153 "http://127.0.0.1:18000/assets/index-DB4b__oJ.js"
From a browser that had clicked the button under the previous version, not a single line arrives.
Fixes
There are three fixes: add no-cache to index.html, add max-age to it, or reload when loading fails.
Add no-cache to index.html
With Cache-Control: no-cache on index.html, the browser asks the server whether index.html has changed every time it opens the page.5 If it hasn't, the server returns 304 (the status meaning not modified) and sends no body.
In nginx, add this:
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache";
}
The new version runs every time the site is reopened, and the window where the previous version runs disappears.6
The cost is one extra round trip to the server each time the page opens. Reopening when nothing has been deployed still gets a 304 every time.
Add max-age to index.html
With Cache-Control: max-age=<seconds>, the browser uses its own copy of index.html without going to the server for the number of seconds you specify, regardless of time since modification.7
With max-age=300 (5 minutes), the previous version runs for about 5 minutes.
The cost is that the window where the previous version runs doesn't go away. If the seconds you specify are longer than what the browser would have guessed, the window gets longer.
Reload when loading fails
When a chunk fails to load, reload the page. In Vite, a load failure is signaled with the vite:preloadError event.8 The code below reloads only once, so that it doesn't keep reloading if the new version also fails.
window.addEventListener('vite:preloadError', () => {
if (!sessionStorage.getItem('reloaded-once')) {
sessionStorage.setItem('reloaded-once', '1');
window.location.reload();
}
});
The window where the previous version runs doesn't change. When a user clicks the button under the previous version, the page reloads once after the chunk fails to load, and the new version runs.
The cost is that in-progress page state is lost on reload. Only users who hit the error switch to the new version.
Which to pick
Start with no-cache. It is the only one of the three that removes the window where the previous version runs. If you want fewer round trips, use max-age instead to cap the window. Reloading on failure doesn't change the window, so combine it with one of the other two and use it to switch users who hit the error to the new version.
Scope
Everything above assumes that index.html has Last-Modified but no Cache-Control, that the deploy removes the previous version's files, and that the browser is Chromium.
Browsers
Only Chromium was measured. According to their source, Firefox and Safari also use their own copy of index.html without going to the server for 10% of the time since modification, the same as Chromium.9
When the previous version's files stay
In setups where the previous version's files stay on the server after a deploy, the window where the previous version runs doesn't change. Clicking the button during that window returns the previous version's chunk, and no error appears.
With no error, it is harder to notice that the previous version is running. The previous version's page keeps calling the newly deployed server-side code, so that code has to stay compatible with it.
A setup that puts build artifacts in S3 and runs aws s3 sync without --delete (the option that removes files that exist only at the destination) is one of these.
When missing paths don't return 404
With try_files $uri /index.html (an nginx setting that returns index.html for paths that don't exist), the request for the previous version's chunk gets a 200 whose body is index.html. No 404 shows up in the access log.
In the access log, look for lines where a hashed file path returned 200 with the same byte count as index.html (321 in the example below).
"GET /assets/feature-DHyZGV0s.js HTTP/1.1" 200 321 "http://127.0.0.1:18024/assets/index-DB4b__oJ.js"
The message that reaches the error tracker is the same Failed to fetch dynamically imported module as with the 404.
With a Service Worker
If a Service Worker (a script that can intercept the page's requests and answer them from its own cache) serves index.html from its own cache, the same symptom appears. In that case, adding no-cache or max-age to index.html has no effect.
To check whether one is registered, run navigator.serviceWorker.getRegistrations() in the browser console.
Built with webpack
The same failure surfaces as ChunkLoadError. The message starts with Loading chunk <chunkId> failed.10
-
The line
lifetimes.freshness = (date_value - last_modified_value.value()) / 10;in Chromium 153.0.8010.12'sGetFreshnessLifetimes. ↩ -
The browser was Chromium headless shell 153.0.8010.12 (the build without a window) driven by Playwright 1.63.0, with nginx 1.30.5 and Vite 8.3.1. The time since modification was produced by shifting the modification time of the served files into the past when starting the container (nginx returns a file's modification time as
Last-Modified). The time until reopening was produced with anAgeheader that the previous version's server added to index.html. The browser treats the response as already that many seconds old when it receives it (RFC 9111, section 4.2.3). At a time since modification of 10 minutes, the results matched actually waiting. Each condition ran 4 times in CI, for 12 trials per data point. The reproduction setup and scripts are in show-your-work. ↩ -
Compared at a time since modification of 100 minutes, side by side with relaunching the browser, within the same deploy. ↩
-
Using the same setup as with no fix, I opened an index.html with a time since modification of 100 minutes and compared different times until reopening. Each condition ran 4 times in CI, for 12 trials per data point. ↩
-
Load error handling in the Vite docs. ↩
-
Firefox: nsHttpResponseHead.cpp. Safari: WebKit's CacheValidation.cpp. ↩
Top comments (2)
I'd add one measurement, because the case that worries me most is the one you found with the previous JS kept: no errors, and the old page keeps calling the new server. The error collector only hears from users who click a chunk that wasn't loaded, so it can't tell you how many browsers are still on the old build.
Have the client send its build version on every API call, for example a header filled from a constant Vite injects at build time through
define, and count requests by version on the server. After each deploy you get a curve of old-version traffic decaying. Its tail should follow your 10% rule, about 17 hours for a weekly deploy with no Cache-Control, plus whatever tabs people leave open, which the curve shows and the cache rule doesn't.It also answers the question your kept-JS section leaves open: when you can remove the server-side compatibility the previous version still depends on. I'd wait for that curve to reach zero rather than for the deploy to finish.
Thanks, this fills the gap I left in the kept-JS section. Error reports only cover users who click a chunk that wasn't loaded, so they can't show how many browsers are still on the old build, and a per-version request count can. It also catches tabs left open across the deploy, which I ruled out in the reproduction and only suggested checking via
performance.timeOrigin. Waiting for the old-version curve to reach zero, rather than for the deploy to finish, is a much clearer criterion for dropping server-side compatibility than anything I wrote.