Topic: Web platform

The site is deployed. Why am I still seeing the old version?

Follow a response through browser and CDN caches, understand s-maxage and validators, and connect fingerprinted JavaScript to the HTML that actually requests it.

A new origin build does not mean the user received it; first check whether this response came from the browser cache, CDN, or origin. · Source: GIPHY

A deployment finishes, but the page looks unchanged. Open DevTools, enable Disable cache, and it works. That result is a useful clue. It does not prove that ordinary visitors can get the new version.

I would start with the requested URLs and response headers. A cache may be following its configuration correctly, including permission to keep using an older response.

This article covers ordinary HTTP caching. Service Worker storage, framework data caches, and back-navigation snapshots need their own checks.

Who is keeping the response?

A browser HTTP cache serves an individual user. A shared cache, such as a CDN, can reuse a public response for different visitors. A JavaScript Map holding query results belongs to application code; calling it a cache does not make it follow HTTP policy.

Changing a file at the origin does not automatically notify every cache holding it. A fresh response may still be reused when policy permits. Stale does not mean the file definitely changed, either. Its freshness lifetime has expired.

MDN’s caching guide explains these categories. Identify the layer supplying a response before deciding why it considers that response reusable.

max-age and s-maxage apply to different caches

Consider a public product description that is identical for every visitor:

Cache-Control: max-age=120, s-maxage=900
Age: 180
Cache Applicable lifetime At an age of 180 seconds
Browser 120 seconds from max-age Stale
Shared cache, such as a CDN 900 seconds from s-maxage Fresh

Both values use seconds. Private caches ignore s-maxage. Shared caches use it in preference to max-age when present.

Age matters too. This response does not receive another fresh 120 seconds when it reaches the browser. Actual age calculations also involve Date and transit time; the example supplies the age directly to illustrate that passing through another layer does not reset it.

This policy can reduce origin traffic for public content. A short browser lifetime still does not guarantee immediate visibility of an origin change: the CDN may have a response it considers fresh. Immediate updates need a compatible purge or versioning strategy. See MDN Cache-Control for directive and age semantics.

A freshness lifetime is not a storage guarantee. A browser can evict an entry or a user can clear it.

no-cache permits storage

With no-cache, ordinary HTTP reuse requires successful validation. The response can still be stored. no-store asks HTTP caches not to store the response.

For article HTML that can receive corrections, this is a starting point to evaluate:

Cache-Control: no-cache
ETag: "article-v3"

For personalized responses that may be stored by a browser but not a shared cache, consider private. If storage itself is unwanted, consider no-store. Neither a login cookie nor HTTPS replaces a deliberate cache policy. These directives are not remote deletion commands for every previously saved copy.

Validators come in pairs

A browser holding article-v3 can ask whether it remains applicable:

GET /guide HTTP/1.1
If-None-Match: "article-v3"

For a normally successful conditional GET, a matching version allows a 304 response and reuse of the stored body. A changed version produces 200 with the new content. A 304 saves body transfer, but still involves a network round trip.

Modification time provides another validator:

Last-Modified: Tue, 08 Sep 2026 02:00:00 GMT

The subsequent request uses:

If-Modified-Since: Tue, 08 Sep 2026 02:00:00 GMT
Response supplies Validation request sends
ETag If-None-Match
Last-Modified If-Modified-Since

Putting Last-Modified in Vary does not ask whether the content changed. Vary has another job. Also, an ETag does not force a request on every use: a fresh response can be reused directly when its policy permits.

See If-None-Match and If-Modified-Since for the request semantics.

Vary selects a matching representation

A URL may return different compressed representations according to Accept-Encoding. A response to a request accepting gzip might contain:

Content-Encoding: gzip
Vary: Accept-Encoding

Content-Encoding describes the encoding of this response. Vary names request headers a cache must consider when matching stored responses. Cache-Control governs storage and reuse; Cache-Control: gzip is not a standard compression directive.

Similarly, a server selecting a language from Accept-Language can declare Vary: Accept-Language. That header does not translate anything. If /en/help and /zh-TW/help already identify separate content and do not vary with that request header, language Vary is unnecessary for that purpose. MDN Vary describes the matching behavior.

Does the HTML point at the new JavaScript?

Suppose the browser still has this HTML:

<script src="/main.aaa.js"></script>

Uploading /main.bbb.js does not make the browser discover it. The HTML must reference the new URL, and the browser must obtain that HTML. None of this requires a new domain.

Even after the new file is available, HTML must point to its URL; the browser does not search for the latest version on its own. · Source: GIPHY

Fingerprinted assets work well with long caching because changed content gets a different URL. For a public resource whose content stays unchanged at that URL, evaluate:

Cache-Control: public, max-age=31536000, immutable

This is not a site-wide setting. An image overwritten daily at a fixed URL, or frequently edited HTML, does not meet the same assumption.

Already-open pages matter during deployment. They may request an old chunk later. Deleting all old assets immediately can turn that request into a 404. Plan when new assets and HTML become available, and how long old assets remain accessible.

Jake Archibald’s caching article explores these deployment tradeoffs through concrete examples. It is a 2016 reference; the relevant lesson here is versioning and update coordination, not old tooling advice.

What to inspect when the page stays old

  1. Inspect the actual HTML, CSS, and JavaScript URLs in Network. Check whether the HTML references the expected build.
  2. Read Cache-Control, Age, ETag, Last-Modified, and Vary alongside the browser’s cache-source information. A 200 status alone does not establish that the origin supplied a new body.
  3. Test both a fresh browser and one that loaded the previous release before deployment. Do not validate only with Disable cache enabled.
  4. After purging the CDN, account for fresh responses already stored in browsers. Clearing one layer does not clear both.

A repeatable update demonstration: 200 → 304 → 200

Download the HTTP cache demonstration and run it with Node.js 24 or newer:

node http-cache-demo.mjs

It starts a temporary server on a random 127.0.0.1 port, sends requests, asserts the results, and closes the server. A run with Node.js 26.8.1 on 2026-09-10 produced this sequence. v1/v2 are demonstration releases; content hashes generate the ETags and asset filenames.

Step Request Response and check
First load GET / 200; HTML references v1, with Cache-Control: no-cache
Unchanged version GET / with the old ETag in If-None-Match 304, empty body
Revalidate after switching to v2 GET / with the same old ETag 200, new ETag and asset URL
Already-open old page loads its asset GET old asset URL 200, still v1 JS
New page loads its asset GET new asset URL 200, v2 JS; long caching applies only to fingerprinted assets
Missing asset GET /missing.js 404, not a successful HTML fallback

Node fetch does not simulate a browser cache here; the check explicitly sends conditional headers. The server only handles the single exact ETag used by this fixture, not a full HTTP validator parser. Use the file to understand the contract, not as a production cache server.

For a real website, load the old release with normal caching, then deploy and navigate again. Inspect HTML asset URLs, ETag, Age, and the responding layer, and compare against a fresh browser context. If it works only with Disable cache selected, the issue remains. A Service Worker needs its own interception and storage checks.

This executable fixture verifies HTTP responses and asset retention, not CDN purges or this site’s live cache headers. The ETag reference explains conditional-request semantics.

What I learned

  • I separate permission to store, permission to reuse directly, and the method of validation.
  • I identify the responding layer before interpreting max-age, s-maxage, and Age.
  • I distinguish validators from Vary: one checks content, the other determines which stored representation matches.
  • I treat updated HTML references and old-asset retention as deployment work, rather than stopping after uploading new JavaScript.

References