Reload users after a deploy
Stop users running a stale React bundle after you deploy: cache index.html correctly, detect the new release, reload or prompt, and recover from failed chunk loads.
A single-page app runs whatever JavaScript the browser loaded. After you deploy, users with the app
open keep running the previous release, and if index.html is cached, reloading can bring the old
release straight back. They see bugs you already fixed, call APIs you've changed, and hit errors
when the app tries to lazy-load a file that no longer exists.
This guide fixes that in four steps for a React app, with extra detail for Vite.
1. Cache index.html correctly
Build tools give every JavaScript and CSS file a content hash in its name, such as
index-3f2a1c.js. Those files never change, so they can be cached forever. index.html is the one
file that points at the current hashes, so the browser must check it on every load:
| File | Cache-Control |
|---|---|
index.html | no-cache |
Hashed assets, such as /assets/* in Vite | public, max-age=31536000, immutable |
no-cache doesn't stop caching: the browser keeps a copy but checks with the server before using
it, so an unchanged file still costs only a small request. Most static hosts let you set headers per
path. Check yours, because some hosts and CDNs cache HTML by default.
With this in place, a reload always gets the current release. The remaining problem is getting users to reload.
2. Stamp each build with a release ID
To notice a new release, the app needs something to compare. Write a random ID into a file on every build, and serve it next to the app. With Vite, add the plugin:
npm install @piplup/cache-buster @piplup/vite-plugin-cache-busterimport { vitePluginCacheBuster } from "@piplup/vite-plugin-cache-buster";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), vitePluginCacheBuster()],
});It writes a new UUID to public/RELEASE at the start of each production build, and when the dev
server starts. Vite copies the file to the output, so the deployed site serves it at /RELEASE. Without Vite, run the CLI before your build:
{
"scripts": {
"build": "piplup-cache-buster --publicDir=public && react-scripts build"
}
}The file changes on every build, so add public/RELEASE to .gitignore.
3. Detect the new release when the app loads
Wrap the app in CacheBuster:
import { CacheBuster } from "@piplup/cache-buster";
createRoot(document.getElementById("root")!).render(
<CacheBuster enabled={import.meta.env.PROD} loading={<Splash />} error={<App />}>
<App />
</CacheBuster>,
);When the app mounts, CacheBuster fetches /RELEASE with a cache-busting query string and compares
it with the ID saved in localStorage from the last visit:
- Same ID, or a first visit: it renders the app.
- New ID: it saves the ID and reloads the page, showing
loadinguntil the reload so the old release never appears. It reloads at most once per release in each tab, so a misconfigured server can't cause a loop. - Request failed, for example offline: it renders
error. That slot has no default, so pass your app there too, as above.
This catches the user who left a tab in the background, or whose browser restored the page from its own cache: the app loads, notices it's out of date, and reloads into the new release.
Or ask first
An automatic reload is fine at startup, before the user has typed anything. If you'd rather prompt,
useReleaseStatus reports the result and never
reloads:
import { useReleaseStatus } from "@piplup/cache-buster";
function UpdateBanner() {
const status = useReleaseStatus({ storageKey: "RELEASE", enabled: import.meta.env.PROD });
if (status !== "new-release") return null;
return (
<Banner>
A new version is available.
<button onClick={() => window.location.reload()}>Reload</button>
</Banner>
);
}The new ID is saved as soon as it's detected, so after the reload the status is no-release.
4. Recover when a lazy chunk is gone
Code-split apps load route chunks on demand with import(). If a deploy replaced those files while
a tab was open, the old release asks for a chunk that no longer exists, and the navigation fails
with an error such as "Failed to fetch dynamically imported module".
Vite fires a vite:preloadError event on window when a dynamic import fails. Reload once when it
does:
window.addEventListener("vite:preloadError", (event) => {
const key = "chunk-reload-at";
const last = Number(sessionStorage.getItem(key));
if (Date.now() - last < 10_000) return;
sessionStorage.setItem(key, String(Date.now()));
event.preventDefault();
window.location.reload();
});event.preventDefault() stops Vite rethrowing the error while the page reloads. The
sessionStorage timestamp skips a second reload within ten seconds, so a chunk that is genuinely
missing reaches your error boundary instead of reloading forever. After the reload, CacheBuster sees the new release
ID and the user is on the current build.
Webpack-based setups throw a ChunkLoadError instead; catch it in the error boundary around your
lazy routes and reload the same way.
Tabs that stay open
CacheBuster checks once per page load. A dashboard that stays open all week won't notice a deploy
until it reloads or a chunk fails. If that matters for your app, check again when the user comes
back to the tab:
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState !== "visible") return;
const res = await fetch(`/RELEASE?v=${Date.now()}`);
if (!res.ok) return;
const latest = (await res.text()).trim();
const known = localStorage.getItem("RELEASE");
if (known && latest !== known) showUpdateBanner();
});It reads the ID CacheBuster saved under its default storageKey, "RELEASE". Show a banner here
rather than reloading, since the user may be halfway through something.
Checklist
index.htmlis served withCache-Control: no-cache, hashed assets with a longmax-age.- The build writes a new
RELEASEfile, and it's reachable at/RELEASEon the deployed site. CacheBusterwraps the app, witherrorset andenabledonly in production.- A
vite:preloadErrorhandler reloads once when a chunk is missing.
FAQ
Why do users still see the old version of my React app after I deploy?
A single-page app keeps running the JavaScript the browser already loaded, so an open tab stays on the old release until the page is reloaded. If index.html is cached, even a reload can load the old bundle. Serve index.html with Cache-Control: no-cache and detect new releases in the app.
How do I force a React app to refresh after a new deployment?
Write a unique release ID into a file at build time, fetch it when the app loads and compare it with the ID saved from the last visit. When they differ, call window.location.reload(). @piplup/cache-buster does the comparison and the reload, and its CLI or Vite plugin writes the release file.
How do I fix "Failed to fetch dynamically imported module" after a deploy?
The tab is running the old release and asked for a lazy-loaded chunk that the new deploy removed. Vite fires a vite:preloadError event on window when this happens; listen for it and reload the page once, with a sessionStorage guard so a chunk that is genuinely missing doesn't cause a reload loop.
Should I reload automatically or ask the user?
Reload automatically when the check runs at startup, before the user has done anything. Ask with a banner when the app is already in use, since a reload throws away unsaved form input. CacheBuster does the first; useReleaseStatus reports the status so you can build the second.
Does the check catch a deploy while a tab stays open?
The @piplup/cache-buster check runs once per page load, so a tab that stays open for days only notices on its next load. Handle failed chunk loads so those tabs recover on navigation, and add your own check on visibilitychange if users keep the app open for long periods.