We Deployed an Hour Ago. Why Do Users Still See the Old Version?
Picture a frontend interview. The interviewer leans back:
We shipped a fix an hour ago. The build is fine and the server has the new files. Support says some users still have the bug. What’s going on?
The first answer comes fast: “The browser cached it. Tell them to hard refresh.” That’s not wrong. It just doesn’t explain anything yet: which file was cached, by what, for how long, and why only some users. Every follow-up below is a different place an old copy can live.
Question 1: Which File Is Old?#
Say the browser did cache it. Which file?
Probably not the JavaScript. Most build tools put a hash of the content in each asset’s file name:
<!-- index.html -->
<script type="module" src="/assets/app.3f9a1c.js"></script>
When the code changes, the hash changes, so the new build ships app.8b1e07.js. The browser has never seen that URL, so it can’t have it cached.
The file that keeps its name is index.html. If the browser reuses yesterday’s HTML, it asks for yesterday’s script, gets it from cache or from the server, and runs yesterday’s code. So the question is really: why did the browser reuse old HTML?
Question 2: No Cache Header Means No Caching, Right?#
Our server sends no
Cache-Controlonindex.html. So it isn’t cached.
This is the follow-up most people miss. Without Cache-Control or Expires, a browser is allowed to guess how long a response stays fresh. HTTP calls this heuristic freshness (RFC 9111). The usual guess is 10% of the time since
Last-Modified:
HTTP/1.1 200 OK
Last-Modified: Tue, 07 Oct 2026 09:00:00 GMT
Date: Fri, 17 Oct 2026 09:00:00 GMT
The file was last changed ten days before this response. Ten percent of ten days is one day. For the next day, the browser can reuse this HTML without asking the server, and your deploy is invisible to that user.
The older the file, the longer the guess: a page that hasn’t changed in a year can stay fresh for more than a month.
Here’s that rule for four users who last visited at different times before a deploy. Drag the slider to change how old the file was:
Interactive figure: four users last loaded index.html 3 days, 1 day, 6 hours, and 1 hour before a deploy. With the file last changed 10 days before the deploy, each copy stays fresh for about a day, so the two most recent visitors can get the old HTML for up to 23 hours after the deploy. Try it on the page.
That’s also why only some users see it: it depends on when each of them last loaded the page, and on how old the file was then. And it’s why “refresh” often works: a normal reload goes back to the server even while the page is still fresh.
Many hosts send a Cache-Control header by default, so this depends on where you deploy. Check what yours actually sends instead of assuming.
Question 3: Then Turn Caching Off#
Fine. Send
no-storeon everything.
That fixes the bug and makes every visit download everything again. The better answer splits files into two kinds:
# index.html: keep a copy, but ask the server before every use.
Cache-Control: no-cache
ETag: "5d8c72a5"
# /assets/app.3f9a1c.js: this URL will never have different content.
Cache-Control: public, max-age=31536000, immutable
no-cachedoesn’t mean “don’t cache”. It means “store it, but revalidate before using it”. The browser sendsIf-None-Match: "5d8c72a5". If nothing changed, the server answers304 Not Modifiedwith no body: one small round trip.no-storeis the one that means “don’t keep a copy”. Use it for responses that must never be stored, like a page with private data. It can also cost you the back/forward cache: some browsers won’t keep ano-storepage there, so Back loads it again.- Hashed assets get a year and
immutable. Their name changes when their content does, so the browser never needs to ask about them again.
Figure: a second visit under each header. With no-store, the browser downloads the page every time. With no-cache, it sends If-None-Match and gets a 304 with no body, or a 200 with the new page after a deploy. With max-age and immutable, it sends no request for app.3f9a1c.js; after a deploy, the new HTML names a new URL.
no-storeindex.html
Browser cache: Nothing kept
- GET /
- 200 OKthe whole page again
Downloads everything, every visit.
Browser cache: Nothing kept
- GET /
- 200 OKthe new page
New version, at full price.
no-cacheindex.html
Browser cache: Copy with ETag "5d8c72a5"
- GET /If-None-Match: "5d8c72a5"
- 304 Not Modifiedno body
One small round trip, then its own copy.
Browser cache: Copy with ETag "5d8c72a5"
- GET /If-None-Match: "5d8c72a5"
- 200 OKnew page, ETag "a91f0c3e"
The ETag didn't match, so it gets the new version.
max-age=31536000, immutableapp.3f9a1c.js
Browser cache: Fresh copy
- no request
No request at all.
Browser cache: Fresh copy, never asked about again
- GET app.8b1e07.jsthe URL the new HTML names
- 200 OKnot in cache yet
New code comes from a new URL.
Question 4: Why Put a Hash in the Name?#
Why not keep
app.jsand add?v=42on every deploy?
Two reasons.
The hash belongs to one file. A deploy that only changes the settings page changes that chunk’s hash, plus the hash of the chunk that imports it, since its code now names a different file (Rollup includes “any referenced file hashes” in a chunk’s hash). Returning users download two small chunks instead of the whole app. A
?v=42 build number changes every URL on every deploy, so everything is downloaded again.
And the hash describes the content, not the deploy. Two builds of the same code give the same name, and a different name always means different bytes. That’s what makes a year of max-age safe.
Interactive demo: a returning user has build 42 cached. After a deploy that only edits the settings chunk, content hashes change two URLs (settings and the entry chunk that imports it), so the user downloads 20 kB. With ?v=43, every URL changes and the user downloads all 210 kB. Try it on the page.
A returning user has build 42 in their cache.
Content hash in the name
cachedcachedcachedcachedcached
Downloads
Build number in the query
cachedcachedcachedcachedcached
Downloads
Question 5: Some Users Still Get It for an Hour#
We fixed the headers. Now some users get the old version for up to an hour after each deploy, and then it stops.
A delay with a fixed length points to a cache with a fixed lifetime. Your browser headers are fine. Something between the browser and the server is holding the HTML: a CDN.
A CDN reads Cache-Control too, but most let you override it. An edge rule like “cache HTML for one hour” overrides your headers. Look at the response:
Cache-Control: no-cache
Age: 2412
Age is the number of seconds the response has been sitting in a shared cache. If it’s above zero, a CDN served this response from its cache, and the browser’s no-cache revalidation went to the CDN, not your server. Many CDNs also add their own header, like cf-cache-status: HIT or x-cache: HIT.
The fixes: purge the HTML from the CDN as part of the deploy, or tell the CDN directly how long to keep it. The CDN-Cache-Control header (RFC 9213) applies only to CDNs, and browsers ignore it:
Cache-Control: no-cache
CDN-Cache-Control: max-age=60
Cloudflare, Vercel, and Netlify read CDN-Cache-Control. CloudFront and Fastly don’t list it in their docs. They read s-maxage, which applies to every shared cache: Cache-Control: max-age=0, s-maxage=60. Either way, an edge rule in the CDN dashboard can still override what your headers say, so check those too.
Question 6: A Blank Screen With No Cache Involved#
A user opened the app yesterday and left the tab open. Today they click “Settings” and get a blank screen.
Nothing is cached here. The old version is the page itself, still running in memory. The tab loaded yesterday’s HTML and yesterday’s code, and that code still knows yesterday’s file names.
“Settings” is loaded on demand:
const settings = await import("./settings.4c2d9e.js");
Today’s deploy replaced settings.4c2d9e.js with settings.a71f30.js and deleted the old file. The request returns 404, the import() promise rejects with a TypeError, and nobody handles it. In Chrome the error reads:
Failed to fetch dynamically imported module: https://example.com/assets/settings.4c2d9e.js
Three fixes, and real apps often use all of them:
- Keep old assets for a while. Deploy new files next to the old ones and delete old ones days later, not right away. Hashed names never collide, so this is safe.
- Reload when a chunk is missing. A failed dynamic import in a long-lived tab usually means a newer deploy. Reload once to get the new HTML. Don’t match on the error message: each browser words it differently.
- Tell users a new version is out. Poll a small
version.json, or check a header on API responses, and show a “Reload to update” message.
Try it: deploy, then open Settings in the old tab. Then turn on a fix and start over.
Interactive demo: a tab running build 41, a server that gets build 42, and a Settings page loaded with import(). After the deploy, opening Settings requests settings.4c2d9e.js, which the server deleted, so the import fails and the screen stays blank. Keeping old assets, reloading once on a missing chunk, or showing an update banner each avoid it. Try it on the page.
Server
async function loadRoute(load) {
try {
const module = await load();
sessionStorage.removeItem("reloaded-for-deploy");
return module;
} catch (error) {
// This tab's code points to files a newer deploy removed. Reload once to get the new HTML.
if (!sessionStorage.getItem("reloaded-for-deploy")) {
sessionStorage.setItem("reloaded-for-deploy", "1");
location.reload();
}
throw error;
}
}
const settings = await loadRoute(() => import("./settings.4c2d9e.js"));
The sessionStorage flag stops a reload loop: if the chunk is still missing after one reload, the problem isn’t the deploy.
Question 7: Now Add a Service Worker#
The app has a service worker that caches the app shell for offline use. What changes?
The service worker sits in front of the HTTP cache. If it answers index.html from its own cache, your Cache-Control headers don’t matter. That cache only changes when your service worker code changes it.


So the question becomes: when does a new service worker take over?
- On a navigation, the browser checks whether
sw.jschanged. By default this check skips the HTTP cache forsw.jsitself. - If it changed, the new worker installs and then waits. The old worker keeps controlling every open tab.
- The new one takes over only when no tab uses the old one anymore. Reloading doesn’t do it, however many times you try: the new page starts loading before the old one goes away, so the old worker always has a tab. Closing every tab does.
You can call self.skipWaiting() to take over right away, but then the new worker controls a page that’s still running the old code. That’s Question 6 again: old code asking for files the new worker doesn’t have.
The safer pattern: let the new worker wait, tell the user, and switch when they say so.
Try all three. Deploy v2, then reload, open, and close tabs:
Interactive demo: two tabs controlled by service worker v1. After v2 is deployed, the next navigation installs it and it waits. Reloading a tab keeps it on v1; closing every v1 tab lets v2 activate. With skipWaiting(), v2 takes over at once, and tabs end up running v1 code under a v2 worker. With the banner, the user clicks Reload and every tab reloads onto v2. Try it on the page.
Registration
installing
waiting
active
// In the page
const registration = await navigator.serviceWorker.register("/sw.js");
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
worker.addEventListener("statechange", () => {
// "installed" with an existing controller means an update is waiting, not a first install.
if (worker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner(() => worker.postMessage("skip-waiting"));
}
});
});
navigator.serviceWorker.addEventListener("controllerchange", () => location.reload());
// In sw.js
self.addEventListener("message", (event) => {
if (event.data === "skip-waiting") self.skipWaiting();
});
A worker may already be waiting when the page loads, from a visit that never clicked the banner. Check registration.waiting at startup too.
Question 8: Prove It#
A user sends a screenshot of the old version. How do you find out which layer served it?
Go from the outside in.
- Ask the page. Put the build ID in the HTML, like
<meta name="build" content="2026-10-17.3">, and show it somewhere a user can read it. Now “old version” has a number. - Skip the browser.
curl -sI https://example.com/has no browser cache and no service worker. If it returns old HTML, the problem is the CDN or the server. CheckAgeand the CDN’s cache header. - Read the Network panel. The size column says where a response came from: in Chrome,
(memory cache),(disk cache), or(ServiceWorker). A304status means the browser asked and the server said “unchanged”. - Look at the service worker. DevTools’ Application panel shows whether a new worker is installed and waiting, and Cache Storage shows what it saved.




The steps inside the browser can run as a script. It compares the tab’s build ID with what the server sends now, reads where the tab’s HTML came from, checks the service worker, and asks the server whether the files the tab loaded are still there. Open DevTools on the page that shows the old version and paste it into the Console:
The snippet is a script to paste into the DevTools console. Copy it on the page, or build it from the source.
It only requests that page and its own files. When it finds a problem, copy(staleDeployCheckPrompt) copies a prompt you can give your coding agent to fix it in your repo. If you haven’t pasted into the Console before, Chrome asks you to type allow pasting first.
The Debrief#
The first answer was “the browser cached it”. The follow-ups found four places an old version can live, and one assumption that hides them. Here’s each scenario as a request going down the layers until one answers:
Figure: the layers a request passes, from the top: the open tab, the service worker, the HTTP cache, the CDN, and the origin. Which one answers:
- No Cache-Control: Old HTML, and no request left the browser. How you spot it: Size column: (disk cache).
- CDN edge rule: Old HTML. The browser did revalidate, but with the CDN, not your server. How you spot it: Age: 2412 and cf-cache-status: HIT.
- Tab open since yesterday: 404, so import() rejects and Settings stays blank. How you spot it: A failed dynamic import in the console.
- Service worker shell: Old shell from v1's cache. Your Cache-Control headers never ran. How you spot it: Size column: (ServiceWorker), and a waiting worker in the Application panel.
- All fixed: New HTML, build 42. How you spot it: The build ID in the page matches the deploy.
Request:
The open tab code in memory
Service worker Cache Storage
HTTP cache in the browser
CDN shared edge cache
Origin server has build 42
| Layer | When it keeps the old version | Fix | How you spot it |
|---|---|---|---|
| HTTP cache | No Cache-Control, so the browser guesses |
no-cache on HTML, immutable on hashed assets |
A cache label in the Network panel’s size column |
| CDN | An edge rule or s-maxage holds the HTML |
Purge on deploy, or CDN-Cache-Control |
Age above zero, CDN cache header |
| The open tab | The page was loaded before the deploy | Keep old assets, reload on a missing chunk | A failed import() |
| Service worker | It serves the shell from its own cache | Let the new worker wait, switch when the user agrees | A waiting worker in DevTools |
| Your mental model | “No header means no cache” | Read the headers your host actually sends | curl -sI |
no-cachemeans “revalidate”,no-storemeans “don’t keep it”.- A content hash in the file name is what makes a year of caching safe.
Ageabove zero means a shared cache answered.- A deploy doesn’t change tabs that are already open. Plan for old code meeting new files.
These checks are now a tool. stale-deploy-check runs the outside-in steps from Question 8 against a URL: the headers on your HTML and hashed assets, and what the CDN sends. It exits with an error when something can leave users on an old version, so it can run in CI after each deploy:
npx stale-deploy-check https://example.com/
Headers can’t show the open tab or the service worker. The snippet from Question 8 checks them in the tab in front of you, and an agent skill reads your repo for the code that controls them: npx skills add ikhattab/stale-deploy-check.
It’s a work in progress: expect rough edges and missed cases. If it gets your site wrong, open an issue.