We Deployed an Hour Ago. Why Do Users Still See the Old Version?

9 min readUpdated Oct 22, 2026

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-Control on index.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:

3 days before
1 day before
6 hours before
1 hour before

Each user's copy stays fresh for 10% of the file's age when they loaded it. Where that window reaches past the deploy, their next visit reuses the old HTML without asking the server.

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-store on 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-cache doesn’t mean “don’t cache”. It means “store it, but revalidate before using it”. The browser sends If-None-Match: "5d8c72a5". If nothing changed, the server answers 304 Not Modified with no body: one small round trip.
  • no-store is 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 a no-store page 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.
Second visit:

no-storeindex.html

Browser cache: Nothing kept

  1. GET /
  2. 200 OKthe whole page again

Downloads everything, every visit.

no-cacheindex.html

Browser cache: Copy with ETag "5d8c72a5"

  1. GET /If-None-Match: "5d8c72a5"
  2. 304 Not Modifiedno body

One small round trip, then its own copy.

max-age=31536000, immutableapp.3f9a1c.js

Browser cache: Fresh copy

  1. no request

No request at all.

The same second visit under each header. Switch to “we deployed since” to see that no-cache still catches the deploy, and that the immutable file is never asked about: the new HTML just names a new URL.

Question 4: Why Put a Hash in the Name?#

Why not keep app.js and add ?v=42 on 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.

A returning user has build 42 in their cache.

Content hash in the name

  • cached
  • cached
  • cached
  • cached
  • cached

Downloads 0 kB

Build number in the query

  • cached
  • cached
  • cached
  • cached
  • cached

Downloads 0 kB

One returning user, two naming schemes. Deploy a change and count what they download again.

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:

  1. 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.
  2. 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.
  3. 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.

example.com

Server

    Fixes
    The tab only asks for settings.4c2d9e.js when you first open Settings. If you open it before the deploy, the chunk is already in memory and keeps working.
    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.

    Chrome DevTools Network panel with four requests: the document, a stylesheet, an SVG, and a script. All have status 200, and the Size column says (ServiceWorker) for each one.
    The same app with a service worker that cached its shell. Every row, the document included, came from the worker.

    So the question becomes: when does a new service worker take over?

    1. On a navigation, the browser checks whether sw.js changed. By default this check skips the HTTP cache for sw.js itself.
    2. If it changed, the new worker installs and then waits. The old worker keeps controlling every open tab.
    3. 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:

    Registration

    installing—

    waiting—

    active—

      A deploy does nothing until a navigation checks sw.js. Then the new worker waits for every tab on the old one to close. Reloading doesn't count.
      // 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. Check Age and 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). A 304 status 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.
      Chrome DevTools Network panel. The document, localhost, has status 304 and a size of 0.2 kB. The script, stylesheet, and SVG below it have status 200 and (disk cache) in the Size column.
      A returning visit with the headers from Question 3. The HTML was revalidated (304), and the hashed assets came from the disk cache without a request.
      Chrome DevTools Application panel, Service workers section. Status shows #0 activated and is running, and below it #1 waiting to activate, with a skipWaiting button. Clients lists one open page.
      After a deploy, the new worker (#1) is installed and waiting. The old one (#0) still controls the open tab.

      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:

      24 kB, 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:

      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-cache means “revalidate”, no-store means “don’t keep it”.
      • A content hash in the file name is what makes a year of caching safe.
      • Age above 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.

      ← All posts