Browser Internals: Why Browser Caching Is More Complicated Than You Think
Introduction:
Browser caching is one of those topics that seems straightforward until you try to control it precisely. Set a cache header, the browser stores the response, subsequent requests are faster. The concept is simple enough to explain in a sentence.
But in practice, browser caching involves multiple overlapping mechanisms, headers that interact in non-obvious ways, and browser behaviours that differ across vendors and versions. Engineers who treat caching as a solved problem consistently encounter situations where resources are cached longer than expected, invalidated too early, or served stale in ways that are difficult to reproduce and harder to debug.
Understanding how browser caching actually works — not just the happy path but the edge cases and interactions — is the difference between a caching strategy that behaves predictably and one that causes intermittent problems that only appear in production.
There Are Multiple Caches, Not One:
Most engineers think of browser caching as a single mechanism. In reality, a browser maintains several distinct caches that operate independently and serve different purposes.
The HTTP cache — sometimes called the disk cache — stores responses to HTTP requests and is governed by cache control headers. The memory cache stores resources that were used during the current page load and is cleared when the tab is closed. The service worker cache is a programmable cache that gives developers explicit control over what is stored and how it is served. The push cache stores resources pushed via HTTP/2 server push and is cleared at the end of the session.
A request for a resource may be served from any of these caches depending on the context, and the rules governing which cache takes precedence are not always intuitive. Engineers who debug caching problems by checking Cache-Control headers are often looking at only one part of a more complex picture.
Cache-Control Headers Are More Nuanced Than They Appear:
The Cache-Control header is the primary mechanism for controlling HTTP caching behaviour, and it supports a range of directives that interact in ways that are easy to misconfigure.
max-age specifies how long a response can be cached in seconds. s-maxage overrides max-age for shared caches like CDNs but not for browser caches. no-cache does not prevent caching — it requires the browser to revalidate with the server before using a cached response. no-store actually prevents caching. must-revalidate requires revalidation only after the max-age has expired.
The distinction between no-cache and no-store is particularly important and consistently misunderstood. Setting no-cache on a response does not mean the browser will not cache it — it means the browser will cache it but check with the server before serving it. Engineers who set no-cache expecting to prevent caching entirely and then observe that responses are being cached are encountering this misunderstanding directly.
Revalidation Is Not the Same as Refetching:
When a cached response expires, the browser does not automatically fetch a new copy. It revalidates — it sends a conditional request to the server asking whether the cached version is still current. If the server responds with a 304 Not Modified, the browser serves the cached version without downloading the response body again. Only if the server responds with a 200 OK and a new response body does the browser replace its cached copy.
This mechanism — ETags and Last-Modified headers enabling conditional requests — significantly reduces bandwidth usage for resources that change infrequently. But it also means that an expired cache entry is not the same as an absent one. The browser still has the old content and will continue serving it if the server confirms it has not changed.
Understanding revalidation is essential for diagnosing situations where users appear to be receiving stale content even after a cache expiry time has passed.
Cache Busting Has Its Own Complexity:
The standard approach to forcing browsers to fetch a new version of a resource is cache busting — appending a version identifier or content hash to the resource URL. When the resource changes, the URL changes, the browser treats it as a new resource, and the old cached version is abandoned.
This works well for static assets like JavaScript bundles and stylesheets where the build tooling generates content hashes automatically. It works less well for HTML files, API responses, and dynamically generated content where URL-based cache busting is impractical.
For HTML files specifically, cache busting creates a circular dependency — the HTML file references versioned asset URLs, but the HTML file itself needs to be fetched fresh to know the current asset URLs. Setting a short max-age or no-cache on HTML files while setting long max-age values on versioned assets is the standard solution, but it requires careful coordination between caching strategy and build tooling to implement correctly.
Service Workers Intercept Requests Before the HTTP Cache:
Service workers sit between the browser and the network, intercepting requests and deciding how to respond. They can serve responses from their own cache, fetch from the network, or combine both strategies. Crucially, service worker cache hits bypass the HTTP cache entirely — a response served from a service worker cache is not subject to Cache-Control headers on the original response.
This creates situations where engineers who control caching via headers find that their headers are being ignored — not because the browser is misbehaving but because a service worker is intercepting the request before it reaches the HTTP cache layer.
Service workers also have their own update lifecycle that is separate from HTTP cache expiry. A service worker update does not take effect immediately — by default, the new service worker waits until all tabs using the old version are closed before activating. Engineers who deploy service worker updates expecting immediate cache invalidation are frequently surprised to find users still receiving responses from the old service worker cache.
Vary Headers Multiply Cache Entries:
The Vary header tells caches that the response varies based on specific request headers — Accept-Encoding, Accept-Language, User-Agent — and that separate cache entries should be maintained for requests with different values of those headers.
A response with Vary: Accept-Encoding will be cached separately for clients that accept gzip compression and clients that do not. A response with Vary: Accept-Language will be cached separately for each language the server supports. Adding Vary: User-Agent — sometimes done to serve different content to different browsers — creates a separate cache entry for every distinct user agent string, which can produce an enormous number of cache entries and effectively defeat caching entirely.
Conclusion:
Browser caching is not complicated in concept but is complicated in practice because it involves multiple independent caching mechanisms, headers with non-obvious interactions, revalidation behaviour that differs from refetching, and service worker intercepts that bypass the HTTP cache entirely.
Engineers who invest in understanding these details build caching strategies that behave predictably across browsers and deployment scenarios. Engineers who treat caching as a solved problem after setting a Cache-Control header encounter the complexity eventually — usually during a production deployment where stale content is being served in ways that are difficult to explain and harder to fix under pressure.
Enjoyed this post?
Stay in the loop
New posts + weekly digest, straight to your inbox.
Create a free account
- Save posts to your vault
- Like posts & build history
- New-post alerts
No comments yet. Be the first to comment!