What you'll learn: By the end of this lesson you will be able to list the caches a front-end request can meet (client state, back/forward cache, service worker, browser HTTP cache, CDN, server), say for each one what it is keyed by, who decides its lifetime and whether you can purge it, work out how much staleness the layers can add up to, choose which layers may hold which kind of data, and debug "the user still sees the old price" by reasoning layer by layer.
You want tomorrow's train time. You try to remember it, then check the timetable on your fridge, then walk to the station noticeboard, and only then phone the control room. Each step is slower and each can be out of date. Staff can replace the noticeboard whenever they like, but nobody can walk into your kitchen and swap the timetable on your fridge.
A web page has the same stack, with more layers: copies in the page's memory, the browser's HTTP cache, a service worker's store, a CDN edge and the server, each with its own key and clock. This lesson treats them as one system. HTTP Caching for the Front End teaches one layer in depth, and the back-end view of the same layers is in Cache Layers: Client, CDN, Application and Database.
The running example is the product page of an online store: HTML, fingerprinted JavaScript and CSS, a JSON call (/api/products/123) and a price that can change.
The core idea
- A request meets caches in order, nearest first. The first layer that holds an acceptable copy answers, and the layers behind it never see the request.
- Every layer has its own key, its own clock and its own way to purge. Bugs come from assuming they agree.
- You can purge only what you control. You cannot reach into a visitor's browser, so the dependable way to invalidate a copy there is to change its key, usually the URL.
Figure 1. The layers a request can meet
How to read this: a request moves right until a layer has a fresh copy. Copies in the left zone cannot be reached once stored, so they need short lifetimes or changed keys. You can delete from the right zone. The back/forward cache is not in the row: it restores a whole page on history navigation.
The layers, one by one
| Layer | Holds | Key | Lifetime set by | Can you purge it? |
|---|---|---|---|---|
| Client state (a data-fetching library) | Query results in memory | The query key you choose | Your code (staleTime, gcTime) |
Yes, in that tab, by code; gone on reload |
| Back/forward cache | A snapshot of a whole page | The history entry | The browser | No; refresh in a pageshow handler |
| Service worker with Cache API | Request and Response pairs | The request | Your code only | Only by shipping code that deletes |
| Browser HTTP cache | Responses | Method and URL, plus Vary |
Cache-Control and friends |
No |
| CDN | Responses for many users | Provider-specific, usually the URL | Your headers plus its own settings | Yes, with the provider's tools |
| Server cache | Query results, rendered pages | Whatever your code uses | Your code | Yes |
Client state. A library such as TanStack Query keeps results under a key. Its guide says data becomes stale after staleTime, which defaults to 0, so cached data is shown and refetched straight away, and an unused entry is garbage collected after gcTime, 5 minutes by default. invalidateQueries marks matching queries stale (it overrides staleTime) and refetches the ones on screen. Matching is by key prefix. It reaches only the tab that runs it.
Back/forward cache (bfcache). web.dev describes it as a snapshot of the entire page in memory, including the JavaScript heap. A restore skips the network and revalidation, so the page can show outdated data. The fix it recommends is to check for changes in a pageshow handler when event.persisted is true. A Cache-Control: no-store response historically makes a page ineligible, so reserve it for sensitive pages.
Service worker and Cache API. A service worker gets the first chance to respond to a page's requests; if it does not respond, or calls fetch(), the HTTP cache rules apply beneath it (web.dev). The Cache API is a script-controlled store: MDN says items "do not get updated unless explicitly requested; they don't expire unless deleted", it does not honour HTTP caching headers, and the browser may still delete an origin's cache storage under disk pressure. So a service worker is a cache with no lifetime unless you write one.
Browser HTTP cache. It is private to one user, keyed by at least the method and target URI (RFC 9111), and driven by headers. The freshness order is s-maxage (shared caches only), max-age, Expires, then a heuristic. The deep treatment is in the HTTP caching lesson.
CDN and server. A CDN is a shared cache run by someone else, so anything personalised must be marked private (MDN). Its key and purge mechanism are provider-specific. The server tier, such as a data cache or regenerated pages, can be deleted from in the code path that commits a change; Incremental Static Regeneration and Revalidation covers its front-end side.
One request, cold and warm
The minimal example: the page asks for /api/products/123 three times. First everything is empty, then the data is stale in the client cache but fresh in the HTTP cache, then the HTTP copy has expired.
Figure 2. Three requests for the same product
How to read this: a refetch is not always a network request. The client cache's clock and the HTTP cache's clock are separate, so a stale entry in one can be refilled from a fresh entry in the other.
Keys: what makes two requests "the same"
Most cache bugs are key bugs.
- Client state keys are yours, such as
["product", 123]. Two components with different keys for one datum fetch it twice and can disagree. - The HTTP cache key is the method and URL;
Varyadds the request headers it names. RFC 9111 says a cache must not reuse a stored response without revalidation unless the named request headers match, and aVary: *response never matches. MDN advises againstVary: User-Agent, whose many variations cut reuse. - The Cache API matches a request against stored ones, and its matching also depends on
Vary. - A CDN's key is the provider's rule. A tracking-only query parameter may split one page into many entries, depending on configuration.
Make the URL, the query key and the CDN key describe the same identity.
Staleness: how the clocks stack
Within HTTP, ages travel with the response. A shared cache must send an Age header (RFC 9111), and the next cache adds it to its own estimate. So where every cache on the path is an HTTP cache, a copy fetched from the CDN at Age: 59 with max-age=30 is already stale in the browser, and total staleness stays near the longest lifetime, not the sum.
Caches outside HTTP do not pass an age along. The client-state cache counts staleTime from when the data was stored, and a service worker's Cache API entry has no clock at all. Those lifetimes add on top of the HTTP ones.
Worked example (illustrative numbers). A price is corrected. The server cache is deleted on write. The response carried s-maxage=60 for the CDN and max-age=30 for browsers, and the client cache uses staleTime of 30 seconds.
| Situation | Worst-case staleness a visitor sees |
|---|---|
HTTP caches pass Age, server delete succeeded |
About 60 s at the CDN, then up to 30 s more until the client cache refetches: up to about 90 s |
| The CDN is purged on write | Up to 30 s (client cache) plus up to 30 s (browser HTTP cache): about 60 s |
A service worker answers /api/ cache-first |
Unbounded: the entry stays until your code deletes it |
The third row is the failure mode below. The first two are numbers you can budget: write down how many seconds of staleness each kind of data may have, then set each layer to stay inside it.
Invalidation: what you can actually do
| Layer | Lever | Reach |
|---|---|---|
| Client state | invalidateQueries({ queryKey: ["product"] }) after a mutation |
One tab, matching keys by prefix |
| bfcache | A pageshow handler that checks event.persisted |
One page restore |
| Service worker | Version the cache name and delete old caches when the worker activates | Every visitor, once their browser installs the new worker |
| HTTP cache | Change the URL (fingerprinted files); serve HTML with no-cache |
The next request for that URL |
| CDN | The provider's purge or invalidation tools | Every user of that edge |
| Server cache | Delete or revalidate in the write path | Every server |
Two details decide how well the service-worker row works. The browser checks for a new worker by fetching its script and installing it only when the bytes differ. MDN says that fetch bypasses browser caches if the previous fetch was more than 24 hours ago, which implies it may use them before that. Serve the worker script itself with Cache-Control: no-cache, or register it with updateViaCache: "none", which MDN says means the HTTP cache is not used for the script or its imports. And web.dev notes that the Cache Storage API does not update or delete assets when the server changes, so your code must handle that.
// Page: always check the network for a new worker script.
navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
// sw.js: cache only fingerprinted files. Everything else is left to the browser.
const ASSETS = "assets-v1";
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "GET" || !url.pathname.startsWith("/assets/")) return;
event.respondWith((async () => {
const cache = await caches.open(ASSETS);
const hit = await cache.match(event.request);
if (hit) return hit;
const response = await fetch(event.request);
if (response.ok) await cache.put(event.request, response.clone());
return response;
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(names.filter((n) => n.startsWith("assets-") && n !== ASSETS).map((n) => caches.delete(n))),
),
);
});
This sketch is not run here (it needs a browser). It is safe only because /assets/ files carry a content hash, so a cached file never goes stale: a changed file has a new URL. The same handler on an API route would be the bug.
Which layers may hold which data
Figure 3. A first-pass decision per resource
is asking?"} Q1 -->|"yes"| P["private or no-store:
browser and client state only,
never CDN or service worker"] Q1 -->|"no"| Q2{"URL changes when
content changes?"} Q2 -->|"yes"| L["Every layer: long max-age,
immutable, precache is safe"] Q2 -->|"no"| Q3{"Staleness budget
in seconds?"} Q3 -->|"some"| B["Short s-maxage and max-age,
staleTime within the budget,
delete on write"] Q3 -->|"none"| N["no-cache and read the source;
do not cache in the service worker"]
How to read this: the first two questions are about identity. Only the third needs a number, and every layer you add consumes part of it.
| Layer | Gain | Cost | Wrong when |
|---|---|---|---|
| Client state | No request at all, instant UI | Per tab, lost on reload, own clock | Two views of one datum use different keys |
| Service worker | Offline and repeat-visit control | You write expiry and cleanup, update lag | It caches mutable API data |
| HTTP cache | Free, automatic | Cannot be purged | A mutable URL gets a long lifetime |
| CDN | Offloads origin, near users | Shared (leak risk), purge delay | The response is personalised |
| Server cache | Deletable on write | Another system to keep consistent | It is the only holder of a fact |
The other side of this topic
The back end sees the same stack from the other end: what the CDN, application cache and database cache hold, and how a lifetime handed to a browser sets a floor under staleness. Cache Layers: Client, CDN, Application and Database covers that view. This lesson adds the layers that exist only in the front end: client state, the service worker and bfcache.
In practice: a price that would not update
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
An online store's team wants repeat visits to feel instant. They add a service worker that serves product JSON from the Cache API first, and a client cache with a generous staleTime. Assets are already fingerprinted and HTML is no-cache. A week later, support reports customers seeing an old price on the product page and a different one in the cart.
Starting architecture
Figure 4. The architecture after the change
How to read this: once the Cache API holds a product response, the request never leaves the device, so the CDN's lifetime, the purge and the server delete cannot reach it. (The HTTP cache between the worker and the CDN is omitted.)
Options considered
| Option | Fixes | Costs | Risk |
|---|---|---|---|
| Add expiry inside the worker | Entries age out | Code to write and test | Worker updates still lag |
| Restrict the worker to fingerprinted assets and the shell | Removes the mutable data from the unreachable layer | Repeat-visit JSON comes from the HTTP cache and client state | Slightly slower repeat visits |
| Keep cache-first, refresh in the background | Instant and eventually fresh | Shows the old price first | The old price is still visible to the customer |
The decision
| Field | Decision record |
|---|---|
| Context | Price is mutable data with a small staleness budget; the service worker holds copies with no lifetime that no purge reaches. |
| Decision | The worker caches only fingerprinted files and the app shell. Product JSON uses max-age and stale-while-revalidate at the HTTP layer (browsers that do not support the directive fall back to max-age, per web.dev) and a client staleTime inside the budget. |
| Consequences, good | Every mutable layer is one the team can bound or purge. |
| Consequences, bad | Repeat visits no longer show product data from a local store without a request. |
| Revisit when | A real offline requirement appears for product data. Then add explicit expiry and a visible "last updated" label. |
Rollout and verification
- Ship a replacement worker first. It deletes the old data caches on activation. Serve the script with
no-cacheand register it withupdateViaCache: "none". - Test the budget: change a price in staging and poll the page and the cart until both agree. The time must be inside the written budget.
- Check headers on every layer: the
Ageheader shows a shared cache answered; the cache-control of/api/responses is what the design says. - Watch the cart-versus-page mismatch after rollout; see Real User Monitoring for field data.
The 2 a.m. failure
A price correction goes live at midnight, yet customers keep adding the old price to their carts for days. The CDN and server cache were purged correctly. The cause is Cache API entries in returning visitors' browsers, which have no expiry and which the origin cannot reach.
First three checks: (1) request the JSON from a fresh browser profile and from a returning one and compare; (2) look in the browser's developer tools for responses served by the service worker; (3) check the worker version and whether sw.js itself is cached. Mitigation: deploy the replacement worker that clears the data caches, and keep the old worker's script URL serving no-cache. Follow-up: a review rule that a service worker may cache only URLs whose content never changes.
Architect's notes
- Write the staleness budget per data type before choosing lifetimes.
- Keep mutable data in layers you can purge, and let the layers you cannot reach hold only immutable URLs.
- A service worker is a second application, with its own deployment, versioning and bugs. Add one only for a requirement the other layers cannot meet.
- Align keys: one datum, one identity across query key, URL and CDN configuration.
- Test end to end: change a value and measure how long each layer takes to show it.
Common mistakes
- Caching mutable JSON in a service worker with no expiry.
- Serving
sw.jswith a long lifetime, delaying updates. - Assuming a refetch always hits the network.
- Forgetting that non-HTTP layers do not pass
Age, so their lifetimes add. - Using
no-storeon non-sensitive pages, which costs bfcache. - Different keys for one datum.
- A personalised response in a shared layer without
private.
Key takeaways
- A request meets client state, a service worker, the HTTP cache, the CDN and the server cache in turn; the first fresh copy answers.
- Each layer has its own key, clock and purge method; you can purge only the ones on your side of the network.
- Change the key (the URL) to invalidate what you cannot reach.
- HTTP caches share ages through
Age; client state and the Cache API do not, so their lifetimes add. - A service worker's Cache API has no lifetime unless your code provides one. Limit it to fingerprinted files unless you also write expiry.
- bfcache restores skip revalidation; check for changes in
pageshowwhenpersistedis true. - Write a staleness budget in seconds and set every layer to fit.
Practice exercises
Exercise 1, Starter (30 min): For HTML, fingerprinted JS, product JSON and cart JSON, write which layers may hold each and for how long, using Figure 3.
Exercise 2, Intermediate (2 hours): Fetch a JSON file with a staleTime of 30 seconds from a server that sends max-age=60. Record in the network panel which requests the HTTP cache answers, and explain each against the two clocks.
Exercise 3, Advanced (half day): Write a service worker with a versioned cache and cleanup on activate. Deploy a new version and time how long a returning browser takes to switch, then serve sw.js with a long max-age and measure again.
Related reading
In this course:
- HTTP Caching for the Front End: freshness, validation and
Cache-Control, the browser layer in depth. - Incremental Static Regeneration and Revalidation: the server and CDN tier for rendered pages.
- Edge Rendering: where the code that fills the shared cache runs.
Elsewhere on the site:
- Cache Layers: Client, CDN, Application and Database: the back-end view of the same stack.
- System Design: Caching Basics and CDN and Object Storage.
Official documentation
- RFC 9111: HTTP Caching: private and shared caches, cache key, freshness order,
Age,Vary. - MDN: HTTP caching: fingerprinted URLs,
private,no-cacheversusno-store. - MDN: Cache: the service worker store, with no automatic expiry.
- MDN: ServiceWorkerContainer.register() and ServiceWorkerRegistration.update():
updateViaCacheand update checks. - web.dev: Back/forward cache and web.dev: Keeping things fresh with stale-while-revalidate.
- TanStack Query: Caching and Query invalidation.