HTTP Caching and Service Workers¶
A service worker doesn't replace the browser's HTTP cache. It sits in front of it. Every fetch() a service worker makes still passes through the HTTP cache, follows Cache-Control, and can be answered by a stored response without touching the network. That means PWA caching is really two layers you design together: the headers your server sends, and the logic in your fetch handler. This page covers the HTTP caching model, how it interacts with service workers down to the spec algorithms, request.cache modes, the service worker update rules, bfcache, and production header configurations for the major servers and hosts.
Key takeaways
- The HTTP cache and Cache Storage are independent layers. Responses you return with
respondWith()never enter the page's HTTP cache, but the HTTP cache can answer everyfetch()inside the worker. no-cachemeans "store, but revalidate before every reuse".no-storemeans "never store". Useno-cacheplus anETagfor HTML,sw.jsand the manifest. Saveno-storefor sensitive responses, because it also affects the back/forward cache.- Give fingerprinted assets (
app.3f9a1c.js)max-age=31536000, immutable. Never give unversioned URLs long lifetimes, or your service worker can precache stale bytes straight out of the HTTP cache. - Inside the worker,
request.cache(reload,no-cache,no-store,force-cache,only-if-cached) decides per request whether the HTTP cache may answer. - By default (
updateViaCache: "imports"), browsers revalidate the top-level service worker script on every update check. If a registration hasn't been checked for more than 86,400 seconds, its scripts bypass HTTP freshness entirely. - CDNs are a third cache. Use
s-maxageor targeted headers such asCDN-Cache-Control, and make sure every deploy purgessw.jsand HTML. - bfcache restores skip the service worker entirely. Activating a new worker,
clients.claim(), or apostMessage()to a cached page evicts that page from bfcache.
The layered cache model¶
When a controlled page asks for a resource, the request can be satisfied at several layers. Each layer has its own rules, lifetime and invalidation mechanism:
flowchart TD
Page["Page: navigation, subresource or fetch()"] --> Ctl{"Controlled by a service worker?"}
Ctl -->|"No"| HC
Ctl -->|"Yes"| FE["fetch event in the worker"]
FE -->|"respondWith(caches.match())"| CS[("Cache Storage")]
FE -->|"respondWith(fetch())"| HC{"HTTP cache lookup"}
HC -->|"Fresh match"| Local["Stored response, no network"]
HC -->|"Stale match with validator"| Cond["Conditional request"]
HC -->|"No match"| Net["Unconditional request"]
Cond --> CDN{"CDN or shared cache"}
Net --> CDN
CDN -->|"Edge hit"| Edge["Response from edge"]
CDN -->|"Miss or revalidation"| Origin["Origin server"] The two browser-side stores behave very differently. Mixing them up causes most caching bugs in PWAs:
| Property | HTTP cache | Cache Storage (Cache API) |
|---|---|---|
| Who decides what is stored | The browser, driven by response headers | Your code (cache.put(), add(), addAll()) |
Honors Cache-Control, Expires, Age | Yes | No. It stores and returns responses regardless of headers |
Revalidation (ETag, 304) | Automatic | Manual. You write the network fetch and the put() |
| Eviction | Browser-managed at any time, independent of site data | Only together with the origin's storage bucket (see Storage Quotas & Persistence) |
| Counts toward the origin's storage quota | No | Yes |
| Cache key | URL, method, Vary-selected request headers, network partition key | Request URL and method, plus Vary unless ignoreVary: true |
Partial responses (206) | Supported | put() rejects a 206 with a TypeError |
| Cleared by | "Cached images and files", Clear-Site-Data: "cache" | "Cookies and site data", Clear-Site-Data: "storage", caches.delete() |
Two consequences follow directly:
- Cache Storage is a programmable store, not a cache in the HTTP sense. A response stored with
cache.put()stays there, unchanged, until your code removes it or the whole origin is evicted. ItsCache-Control: max-age=60header means nothing to Cache Storage. Expiration is your job (Workbox'sExpirationPluginkeeps timestamps in IndexedDB for exactly this reason). The Cache Storage API page covers the API surface. - The HTTP cache is the layer you can't control from JavaScript. You can't list, delete or inspect its entries from a page or a worker. All you can do is set headers on responses, pick a
cachemode on requests, or sendClear-Site-Data. Get the headers right, because the service worker has no way to route around a badly cached response except by bypassing the cache on every request.
Browsers also keep a per-document memory cache (for example, for images a document already decoded and for preloaded resources). It can satisfy a request before a fetch event would fire. You can't configure it with headers, and it lasts only as long as the document, so you can mostly ignore it when designing a caching policy.
Cache-Control directives in depth¶
HTTP caching is defined by RFC 9111 (HTTP Caching), with extensions in RFC 5861 (stale-while-revalidate, stale-if-error) and RFC 8246 (immutable). A browser cache is a private cache. CDNs and reverse proxies are shared caches. Several directives exist only to separate the two.
Freshness lifetime and age¶
A stored response is fresh while its current age is less than its freshness lifetime. The cache computes the freshness lifetime from the first match in this order:
s-maxage=N, for shared caches only (private caches ignore it).max-age=N.ExpiresminusDate.- A heuristic, when none of the above exist (see the next section).
The current age includes time the response already spent in upstream caches, which is reported in the Age header. A CDN that serves a 50-minute-old copy of a max-age=3600 response sends Age: 3000, and the browser treats it as fresh for only 10 more minutes. That's intended, and it explains why "max-age=3600" sometimes seems to last much less than an hour.
HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=3600
ETag: "5f1c-62a8b4e0"
Last-Modified: Tue, 15 Sep 2026 08:12:00 GMT
Date: Fri, 25 Sep 2026 10:00:00 GMT
Age: 0
While a response is fresh, a request with the default cache mode is answered locally, with zero network activity. That includes the service worker's fetch() calls.
Heuristic freshness: the risk of sending no header¶
If a response has no max-age, s-maxage or Expires, and its status code is heuristically cacheable, caches may assign it a lifetime anyway. RFC 9110 lists these status codes: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414 and 501. RFC 9111 suggests a lifetime of about 10% of the time since Last-Modified. The engines implement that suggestion differently, and the differences matter:
| Engine | Heuristic for 200, 203, 206 | Status codes treated as fresh forever | Error responses (4xx, 5xx) |
|---|---|---|---|
Chromium (net/http/http_response_headers.cc) | (Date − Last-Modified) / 10, no upper cap | 300, 301, 308 and 410 with no explicit freshness | No heuristic lifetime (freshness 0) |
Firefox (nsHttpResponseHead.cpp) | (Date − Last-Modified) / 10, capped at one week | 410 | No heuristic lifetime for anything ≥ 400 except 410 |
Both engines also treat a Pragma: no-cache response header like Cache-Control: no-cache, for backward compatibility, and they do so even when Cache-Control carries a max-age. That departs from RFC 9111, which says caches should ignore Pragma when Cache-Control is present. Firefox makes one exception: a response that also carries immutable ignores the Pragma. Chromium has the same exception, but only behind its disabled-by-default CacheControlImmutable feature. If a framework or proxy adds Pragma: no-cache to your fingerprinted assets, strip it, or those assets are revalidated on every use no matter what Cache-Control says.
Here's how this goes wrong. A file you last modified 30 days ago, served with a Last-Modified header and no Cache-Control, can be treated as fresh for about three days. If that file is an unversioned /app.js and you deploy a fix, returning visitors can keep running the old code for days. Their service worker will happily precache the stale copy too (see the double-caching problem). Always send an explicit Cache-Control on every response. It is the only way to know how every layer will treat it.
Redirects are the other trap. A 301 or 308 without Cache-Control is cached by Chromium with no expiry at all. If you ever permanently redirect /app to /app/ and later change your mind, browsers that saw the redirect keep following it until their cache entry is evicted. Give permanent redirects an explicit max-age (for example, one day) while a URL layout is still settling. Browsers don't apply heuristics to 404s, but CDNs and proxies may, so send an explicit policy on error responses too.
no-cache, must-revalidate and max-age=0¶
These three are often confused:
no-cache- The response may be stored, but it must be validated with the origin before every reuse. With an
ETagorLast-Modified, validation is a cheap conditional request that usually returns304 Not Modifiedwith no body. This is the right default for HTML documents,sw.jsand the web app manifest. max-age=0, must-revalidate- In practice this is equivalent to
no-cachefor browsers. The response is stale immediately, andmust-revalidateforbids serving it stale when the origin can't be reached. You'll see it in older configurations and as the default on several hosts (Netlify, Vercel, Cloudflare Pages). must-revalidate(with a positivemax-age)- The response is reused freely while fresh. Once stale, it must be revalidated. If revalidation fails, the cache must return an error (typically
504 Gateway Timeout) instead of silently serving stale content.
MDN notes that no-cache doesn't guarantee revalidation for history navigations. When the user presses Back and the page comes from the back/forward cache, the browser restores a snapshot without revalidating anything. Even when bfcache isn't used, the browser may serve the stored response for a history navigation without revalidating, because RFC 9111 treats history navigation as restoring a previous view rather than requesting the resource again. On a controlled page, a non-bfcache Back navigation still dispatches a fetch event, so your worker's strategy decides what the user sees.
no-store¶
no-store forbids storing the response in any cache. It has three costs that often go unnoticed:
- No
304s. Since nothing is stored, there's nothing to validate. Every request downloads the full body. - Weaker bfcache eligibility. Browsers historically refused to put a page into the back/forward cache when the document itself carried
no-store. Chrome now admits such pages under conditions, as covered below, but Safari still treats HTTPSno-storedocuments as ineligible, and you shouldn't count on other engines either. - It says nothing about Cache Storage. A service worker can still
cache.put()ano-storeresponse. The Cache API doesn't readCache-Control. If a response must never be persisted anywhere, your service worker has to check for it (see the runtime-caching guard in Caching Strategies).
Use no-store for responses containing secrets or per-user data that must not survive on disk: account pages, one-time tokens, payment confirmations. Don't use it as a general "please don't cache" switch. no-cache is almost always what you want.
private and public¶
private restricts storage to the user's own browser, so shared caches must not store the response. Use it on every response that depends on cookies or the Authorization header. If you forget private on personalized content served through a CDN with a positive max-age, one user's response can be served to another. That's one of the most damaging caching bugs there is.
public explicitly allows shared caching, even for responses to requests that carried an Authorization header (RFC 9111 otherwise forbids shared caches from reusing those unless public, s-maxage or must-revalidate is present). On ordinary unauthenticated responses with max-age, public changes nothing, but it's harmless and documents your intent.
immutable¶
immutable (RFC 8246) promises that the response body will never change while it's fresh. Browsers that support it skip revalidation for the resource even when the user reloads the page. It only makes sense on URLs that change whenever their content changes, such as build-fingerprinted file names:
Firefox and Safari implement immutable. Chromium doesn't ship it: its network stack contains a CacheControlImmutable feature that is disabled by default. Chrome instead changed its reload behavior in 2017 so that a normal reload revalidates only the main document, not every subresource (Reload, reloaded). The effect for fingerprinted assets is similar: a fresh stored copy is reused on reload without a conditional request.
Why does this matter at all? Before immutable, a normal reload in Firefox revalidated cached subresources, and large sites saw a flood of 304 responses for files that could never change. immutable (shipped in Firefox 49) tells engines with that reload model to skip the conditional request (Using Immutable Caching To Speed Up The Web). It has no effect once the response is stale, and it doesn't stop a hard reload (Shift+Reload, or "Empty cache and hard reload" in DevTools) from bypassing the cache.
stale-while-revalidate¶
stale-while-revalidate=N lets a cache serve a stale response for up to N seconds past its freshness lifetime while it revalidates in the background:
Chrome 75, Firefox 68 and Safari 14 support it in the HTTP cache. The Fetch Standard specifies exactly what happens, and two details matter for service workers:
- The background revalidation happens only when the request's cache mode is
"default"and the request has a client (a document or worker context). - The revalidation request is cloned with cache mode
"no-cache"and service-workers mode"none". It goes straight to the network and doesn't dispatch a fetch event to your worker. You'll never see it in your fetch handler, and it can't be served from Cache Storage.
Don't confuse the header with the service worker stale-while-revalidate strategy. The header makes the HTTP cache serve stale content. The strategy makes your code serve content from Cache Storage and refresh it. Combining them without thinking causes the problem described in the double-caching problem.
stale-if-error¶
stale-if-error=N allows a cache to serve a stale response when revalidation fails with a 500, 502, 503 or 504. MDN notes that no browser supports it as a request directive, and its practical consumers are CDNs (Vercel documents support for it, for example). Implement the user-facing version in your service worker: catch the failed fetch and fall back to Cache Storage. That's the core of every offline fallback.
Directive reference¶
| Directive | Who reads it | Meaning | Typical PWA use |
|---|---|---|---|
max-age=N | All caches | Fresh for N seconds from generation | Hashed assets (31536000), short-lived public data |
s-maxage=N | Shared caches only | Overrides max-age at the CDN, with proxy-revalidate semantics | Let the CDN hold HTML briefly while browsers revalidate |
no-cache | All caches | Store, but revalidate before every reuse | HTML, sw.js, manifest, API JSON |
no-store | All caches | Never store | Sensitive, per-user responses |
must-revalidate | All caches | No stale reuse after expiry, even if the origin is unreachable | Data where stale is worse than an error |
proxy-revalidate | Shared caches | must-revalidate for shared caches only | Rarely needed |
private | All caches | Only the browser may store it | Anything personalized |
public | All caches | Shared caches may store it, even with Authorization | Public, cacheable responses |
immutable | Firefox, Safari | Don't revalidate while fresh, even on reload | Fingerprinted assets |
stale-while-revalidate=N | Browsers, CDNs | Serve stale for N s while revalidating in the background | Avatars, non-critical public JSON |
stale-if-error=N | Mainly CDNs | Serve stale for N s if the origin errors | CDN resilience |
no-transform | Intermediaries | Don't recompress or re-encode | Assets with SRI hashes |
Validators: ETag, Last-Modified and 304 Not Modified¶
Revalidation is what makes no-cache cheap. When a stored response is stale (or the policy is no-cache), the browser sends a conditional request using the validators it stored:
sequenceDiagram
participant B as Browser HTTP cache
participant S as Server
B->>S: GET /index.html
S-->>B: 200 OK, ETag "a1", Cache-Control no-cache, 18 KB body
Note over B: Stored. Must be validated before reuse.
B->>S: GET /index.html with If-None-Match "a1"
S-->>B: 304 Not Modified, ETag "a1", no body
Note over B: Reuses stored body and merges updated headers
B->>S: GET /index.html with If-None-Match "a1"
S-->>B: 200 OK, ETag "b7", new body
Note over B: Replaces the stored response Key mechanics from RFC 9110 and RFC 9111:
ETagwins overLast-Modified. When a request carries bothIf-None-MatchandIf-Modified-Since, the server evaluatesIf-None-Matchand ignoresIf-Modified-Since. Send anETagwhenever you can.Last-Modifiedhas one-second resolution and breaks when build tools reset file timestamps.- Strong vs weak validators.
ETag: "abc"is strong: byte-for-byte identity.ETag: W/"abc"is weak: semantically equivalent content.If-None-Matchuses weak comparison, so both work for304s. Weak ETags can't be used forRangerequests withIf-Range. Compression can change validators: nginx, for example, turns a strong ETag into a weak one when it gzips a response on the fly. - A
304updates the stored headers. The cache mergesCache-Control,Expires,ETag,Dateand other headers from the304into the stored response. You can change a resource's caching policy without changing its body, as long as the server sends the new headers on304s. - ETags must be identical across servers. Behind a load balancer, every server must generate the same ETag for the same bytes. Apache's
FileETagdefault has beenMTime Sizesince 2.4 (it used to include the inode, which differs per machine), and nginx derives ETags from modification time and length. If your deploy writes files with different timestamps on each server, clients bounce between ETags and never get a304. Content-hash ETags (ApacheFileETag Digest, or ETags generated by your build or CDN) avoid this. - A service worker can revalidate too. When your worker calls
fetch(request)with the default mode (for a stale entry) or theno-cachemode, the HTTP cache addsIf-None-MatchandIf-Modified-Sincefrom the stored validators for you. If you addIf-None-MatchorIf-Modified-Sincemanually with the default mode, the Fetch Standard switches the request tono-store, so the HTTP cache neither answers it nor stores the result. You handle the304yourself.
In Resource Timing, the spec defines transferSize as 0 for a response served entirely from the local cache (the Fetch "cache state" local), as a fixed 300 bytes for a response revalidated with a 304 (cache state validated), and as the encoded body size plus 300 otherwise. The constant 300 stands in for header bytes, which the spec doesn't expose because they could reveal cookies. deliveryType is "cache" for both the local and validated states. Cross-origin resources report 0 unless they send Timing-Allow-Origin.
Vary and cache keys¶
Vary tells caches which request headers were used to choose the response. A stored response is reused only if the new request's values for those headers match the ones that produced it:
Rules that matter for PWAs:
Vary: Accept-Encodingis normal. Every compressing server should send it, and browsers send stableAccept-Encodingvalues, so it doesn't fragment the cache.Vary: CookieorVary: User-Agentfragments shared caches so badly that caching is effectively disabled. Vercel's CDN refuses to cache responses that vary onCookie. For personalized responses, useprivateinstead of trying to vary on identity.Vary: *means the response can never be reused by an HTTP cache. The Cache API goes further:cache.put()rejects a response withVary: *with aTypeError.- Cache Storage honors
Varyby default.cache.match(request)compares the headers named in the stored response'sVaryagainst the new request. A response stored from a request withAccept: application/jsonwon't match a later request with a differentAccept. Pass{ ignoreVary: true }when you know the variation doesn't matter. This is a common "why is my cache miss happening" cause. See Cache Storage API. - Navigation preload needs
Vary. Preload requests carryService-Worker-Navigation-Preload: true. If your server returns something different for them (for example, only the page body), it must sendVary: Service-Worker-Navigation-Preloadso the HTTP cache and CDNs don't serve the partial response to normal navigations. See Navigation Preload. No-Vary-Searchnarrows the key. This response header tells the cache that some query parameters don't affect the response (No-Vary-Search: params=("utm_source" "utm_medium")). Chrome 141 (desktop), Chrome 143 (Android) and Firefox 154 apply it to the HTTP cache. In Chrome it started as a speculation-rules prefetch feature. Cache Storage doesn't understand it, butcache.match(request, { ignoreSearch: true })covers the "ignore the whole query" case.
The HTTP cache key also includes a network partition key. Since Chrome 86, Chrome keys cached resources by top-level site and frame site in addition to the URL. Safari keys by top-level site. A resource loaded on app.example is therefore cached separately from the same URL loaded on other.example, and the old idea of "a shared CDN copy of a library that's already cached from another site" no longer holds. Privacy & Storage Partitioning covers partitioning in full.
How service worker fetches interact with the HTTP cache¶
This is the core of the interaction. A few rules, all derived from the Fetch and Service Worker specifications, explain almost every observed behavior:
- A worker's own
fetch()calls go through the HTTP cache. Fetch runs its normal "HTTP-network-or-cache fetch" algorithm for requests made inside the service worker. A fresh stored response is returned without network activity. A stale one triggers a conditional request. The worker's fetches don't dispatch fetch events to the worker itself, so there's no recursion. - Responses given to
respondWith()never enter the page's HTTP cache. When the worker answers a request, the page's fetch never reaches its own HTTP-cache step. If the worker got the response viafetch(), the HTTP cache may have stored it during that fetch. A response from Cache Storage or a syntheticnew Response()leaves no trace in the HTTP cache. fetch(event.request)preserves the request's cache mode. If the page calledfetch(url, { cache: "no-store" }), forwardingevent.requestkeepsno-store. If you build a new request from the URL (fetch(event.request.url)), you silently reset the mode to"default"and lose the credentials mode, headers and body as well.- Shift+Reload bypasses the service worker. The Service Worker spec's Handle Fetch algorithm returns early for a navigation "initiated with a shift+reload or equivalent", and
navigator.serviceWorker.controllerisnullon such a page. A hard reload is therefore a test of your HTTP headers alone. - HTTP-cache background revalidation bypasses the worker. As described above,
stale-while-revalidaterevalidation runs with service-workers mode"none". - Navigation preload responses come through the HTTP cache. The preload request is an ordinary network request issued in parallel with worker startup. With a
no-cacheHTML policy, it revalidates. With a longmax-age,event.preloadResponsecan resolve to a stale stored copy. - Static routes to
"network"skip the worker but not the HTTP cache. A Static Routing API rule with source"network"sends the request through the regular fetch path, HTTP cache included.
sequenceDiagram
participant P as Page
participant W as Service worker
participant H as HTTP cache
participant N as Network
P->>W: fetch event for /api/feed
W->>H: fetch(event.request), cache mode "default"
alt stored response is fresh
H-->>W: 200 from disk, no network
else stored response is stale and has an ETag
H->>N: GET with If-None-Match
N-->>H: 304 Not Modified
H-->>W: stored body with refreshed headers
else nothing stored
H->>N: GET
N-->>H: 200 plus body
H-->>W: 200, stored if cacheable
end
W->>W: cache.put() into Cache Storage, optional
W-->>P: respondWith(response)
Note over P,H: The page's own HTTP cache step never runs In Chromium DevTools, the Network panel's Size column shows "(ServiceWorker)" for responses a worker provided, and "(disk cache)" or "(memory cache)" for HTTP-cache hits. Requests the worker itself issued appear as separate rows marked with a gear icon, and the Initiator column points at sw.js. Browser DevTools shows how to read these rows.
request.cache modes in depth¶
Request.cache (the cache option of fetch() and new Request()) controls how a single request uses the HTTP cache. It's the tool that lets a service worker decide, per request, whether the HTTP cache may answer. The six modes defined in the Fetch Standard:
| Mode | Fresh stored response | Stale stored response | Nothing stored | Writes to HTTP cache | Request headers the browser adds |
|---|---|---|---|---|---|
default | Returned, no network | Conditional request (or served stale with background revalidation under stale-while-revalidate) | Normal request | Yes | — |
no-store | Ignored | Ignored | Normal request | No | Pragma: no-cache, Cache-Control: no-cache |
reload | Ignored | Ignored | Normal request | Yes | Pragma: no-cache, Cache-Control: no-cache |
no-cache | Conditional request | Conditional request | Normal request | Yes | Cache-Control: max-age=0 |
force-cache | Returned | Returned without validation | Normal request | Yes | — |
only-if-cached | Returned | Returned without validation | Network error (fetch() rejects with TypeError) | No | — |
The browser adds those request headers only if you didn't set Pragma or Cache-Control yourself. It adds them inside the HTTP-network-or-cache step, after the CORS preflight decision, so cache: "no-cache" never causes a preflight. Setting a Cache-Control request header yourself on a cross-origin request does, because Cache-Control isn't a CORS-safelisted request header.
Edge cases worth knowing:
only-if-cachedrequiresmode: "same-origin". TheRequestconstructor throws aTypeErrorif you combineonly-if-cachedwith any other mode. Cached redirects are followed as long as they don't violate the mode.- Manual validators force
no-store. If a request with the default mode carriesIf-Modified-Since,If-None-Match,If-Unmodified-Since,If-MatchorIf-Range, fetch switches it tono-store. - Navigation requests can't be re-created as-is. Passing a
navigate-mode request plus an init object tonew Request()converts its mode tosame-origin. That's fine for fetching, but it's why you passevent.requeststraight through when you don't need to change it. - Support.
Request.cacheis supported in Chrome 64, Firefox 48 (only-if-cachedin 50) and Safari 10.1, so you can rely on it in every browser that runs service workers.
Using cache modes in a service worker¶
The following worker shows each mode used for the job it's good at:
const VERSION = "v42";
const PRECACHE = `precache-${VERSION}`;
const RUNTIME = "runtime-v1";
// Build output: fingerprinted files plus a few stable URLs.
const PRECACHE_URLS = [
"/",
"/offline.html",
"/manifest.webmanifest",
"/assets/app.3f9a1c7e.js",
"/assets/app.91bd02aa.css",
];
// A fingerprinted URL never changes content, so the HTTP cache may serve it.
const isFingerprinted = (url) => /\.[0-9a-f]{8,}\.(?:js|css|woff2|png|svg|webp)$/.test(url);
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(PRECACHE);
const requests = PRECACHE_URLS.map(
(url) =>
new Request(url, {
// (1)!
cache: isFingerprinted(url) ? "default" : "reload",
credentials: "same-origin",
}),
);
await cache.addAll(requests); // Rejects (and fails the install) if any response is not ok.
})(),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
// (2)!
if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirstNavigation(event));
return;
}
if (url.pathname.startsWith("/api/avatars/")) {
event.respondWith(staleWhileRevalidate(event));
}
});
async function networkFirstNavigation(event) {
try {
// HTML is served with "Cache-Control: no-cache", so this is always
// revalidated with If-None-Match and usually costs one 304 round trip.
return (await event.preloadResponse) || (await fetch(event.request));
} catch (error) {
// (3)!
const salvaged = await fetch(event.request.url, {
cache: "only-if-cached",
mode: "same-origin",
}).catch(() => null);
if (salvaged && salvaged.ok) return salvaged;
return (await caches.match("/offline.html")) || Response.error();
}
}
async function staleWhileRevalidate(event) {
const cache = await caches.open(RUNTIME);
const cached = await cache.match(event.request);
const refresh = fetch(event.request, { cache: "no-cache" }) // (4)!
.then(async (response) => {
if (response.ok) await cache.put(event.request, response.clone());
return response;
})
.catch(() => undefined);
if (cached) {
event.waitUntil(refresh); // Keep the worker alive until the refresh finishes.
return cached;
}
return (await refresh) || Response.error();
}
reloadskips any stored copy and refreshes the HTTP cache with the new response. That guarantees the precache for this version contains this deploy's bytes for unversioned URLs such as/and/manifest.webmanifest. Fingerprinted files can't be stale, so they keepdefaultand may be served from disk. Workbox's precaching does the same: entries with arevisionare fetched withcache: "reload", and entries without one usedefault.- Some Chromium DevTools builds have issued requests with
only-if-cachedand ano-corsmode. Forwarding one tofetch()throws, so return early and let the browser handle it. only-if-cachedis a way to salvage something when the network is down. It returns whatever the HTTP cache holds, fresh or stale, without touching the network, and rejects if there's nothing. It works only for same-origin URLs, which is why it uses the URL rather than thenavigate-mode request.no-cacheforces a conditional request even when the HTTP cache holds a fresh copy. Without it, a resource served withmax-age=3600would "revalidate" from the HTTP cache for an hour, and your stale-while-revalidate strategy would keep re-storing the same bytes.
The double-caching problem¶
A resource a service worker manages can live in the HTTP cache and in Cache Storage at the same time, under two different lifetimes. That's not harmful by itself. It becomes a bug when one layer feeds stale data into the other.
Stale bytes baked into the precache¶
This is the classic failure. It happens with unversioned URLs and a positive max-age:
sequenceDiagram
participant U as User's browser
participant H as HTTP cache
participant S as Server
U->>S: Day 1: GET /app.js
S-->>U: v1 body, Cache-Control max-age=86400
Note over H: v1 stored, fresh for 24 hours
Note over S: Day 1, two hours later: deploy v2 of app.js and sw.js
U->>S: Update check for sw.js (revalidated)
S-->>U: New sw.js, install event fires
U->>H: cache.addAll with /app.js, default cache mode
H-->>U: v1 body, still fresh, no network
Note over U: New worker precaches v1 under the v2 cache name The new worker activates with a precache that mixes v2 HTML and v1 JavaScript. Because the precache only changes when sw.js changes, the user can be stuck on that mix until the next deploy. There are three fixes, and production setups usually combine the first two:
- Fingerprint every precached asset so the URL changes with the content (
app.3f9a1c7e.js). A stale HTTP-cache hit is then impossible, because a new URL has no stored copy. - Fetch unversioned precache entries with
cache: "reload", as in the worker above. Workbox does this automatically for entries that carry arevision. - Serve unversioned files with
no-cacheso the HTTP cache always revalidates them.
Service worker "revalidation" answered by the HTTP cache¶
A worker that runs stale-while-revalidate or network-first against a URL served with max-age=3600 isn't talking to the network for most of that hour. Its fetch() returns the fresh HTTP-cache copy. Network-first then isn't network-first, and "revalidate" re-stores the same bytes. If you need true network semantics in the worker, either send no-cache from the server or pass cache: "no-cache" (revalidate) or cache: "no-store" (always download) on the worker's fetch.
Two copies on disk¶
A 2 MB video segment or font fetched by the worker with the default mode and then put() into Cache Storage takes up disk twice: once in the HTTP cache and once in Cache Storage. Only the Cache Storage copy counts toward the origin's quota, but both use the user's disk. For large, worker-managed assets, fetch with cache: "no-store" so only your managed copy is written:
const MEDIA_CACHE = "media-v3";
async function cacheLargeAsset(url) {
// no-store: the HTTP cache neither answers nor stores this response,
// so the only copy on disk is the one in Cache Storage.
const response = await fetch(url, { cache: "no-store", credentials: "same-origin" });
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
const cache = await caches.open(MEDIA_CACHE);
await cache.put(url, response);
}
Clearing one layer but not the other¶
"Clear cached images and files" in browser settings empties the HTTP cache but leaves Cache Storage and the service worker alone. The site keeps running from Cache Storage, which surprises users who are trying to fix a broken app. "Clear cookies and site data" does the reverse for your origin. Clear-Site-Data lets you do either from the server:
Clear-Site-Data: "cache"
Clear-Site-Data: "storage"
Clear-Site-Data: "cache", "storage"
"cache" clears the HTTP cache for the origin. "storage" clears DOM storage (Cache Storage, IndexedDB, localStorage, OPFS) and unregisters service workers, which makes it the last-resort kill switch for a broken worker. The header is supported in Chrome 61, Firefox 63 and Safari 17, and is honored only on secure origins. MDN marks Chrome's "cache" handling (Chrome 127 and later) as partial: some requests may still be served from cache until the tab reloads or the page is opened in a new tab, and clearing the cache can cause seconds-long hangs. Firefox has supported "cache" since Firefox 138. Send it from a dedicated endpoint (for example, /reset) that you can link users to, not on every response. Service Worker Security covers kill-switch strategies.
Rules that prevent double caching¶
- Fingerprint everything that's long-lived. Only fingerprinted URLs get a long
max-age. - Give unversioned URLs
no-cache, never a positivemax-age, if a service worker precaches them. - In the worker, use
cache: "reload"for precache fetches of unversioned URLs andcache: "no-cache"for revalidation fetches. - For large assets the worker manages explicitly, use
cache: "no-store"to avoid storing them twice. - Don't rely on Cache Storage to honor HTTP headers. Implement expiration yourself or with Workbox. See Precaching & Runtime Caching.
Service worker updates: the 24-hour rule and updateViaCache¶
The service worker script is the one resource whose HTTP caching can lock users into an old version of your entire app. The spec therefore gives it special rules. Updating Service Workers covers the update flow from the page's perspective. This section covers the HTTP layer.
When the browser checks for an update¶
The browser runs the Soft Update algorithm (an update check without a page calling update()) in these cases:
- A navigation to a URL in the worker's scope.
- A functional event such as
push,syncornotificationclick, but only if the registration is stale. - A subresource request from a controlled page, again only if the registration is stale.
A page can also call registration.update() at any time, and navigator.serviceWorker.register() runs an update when called with a changed script URL, worker type or updateViaCache value.
The spec defines stale precisely: a registration is stale when its last update check time is non-null and more than 86,400 seconds (24 hours) in the past. The last update check time is set only when a script response did not come from the local cache. A 200 from the network or a 304 revalidation both count. A fresh HTTP-cache hit doesn't.
How the script request is built¶
For each update, the browser fetches the top-level script with these properties (from the Update algorithm in the Service Worker specification):
- A
Service-Worker: scriptrequest header, which servers can log or use to identify worker script fetches. - Service-workers mode
"none"(the fetch never goes through a worker) and redirect mode"error". A redirectedsw.jsfails the update. - Cache mode
"no-cache"if any of the following is true:- the registration's
updateViaCacheisn't"all"(so with the default"imports"and with"none"), - the update was forced to bypass the cache (the spec suggests implementations use this for developer tools),
- a worker already exists for the registration and the registration is stale.
- the registration's
Otherwise the HTTP cache may answer. The spec adds a note: even in that case, the user agent honors the script's max-age in the network layer.
The response must have a JavaScript MIME type, or the update is rejected with a SecurityError. The browser reads the Service-Worker-Allowed response header to allow a scope above the script's directory (see Registration & Scope).
Imported scripts¶
For classic workers, scripts loaded with importScripts() are fetched with "no-cache" only if updateViaCache is "none", the update was forced, or the registration is stale. With the default "imports", a fresh HTTP-cache copy of an imported script is used. The same rule applies when a new worker calls importScripts() during its first run: the spec's fetch hook for imported scripts checks exactly those three conditions. Once a worker is installed, its imported scripts are served from the worker's script resource map, never from the network, so an importScripts() call after installation can only load URLs that were already imported during install.
The staleness backstop rarely helps here. Every revalidation of the top-level script (a 304 included) resets the registration's last update check time, so a registration used every day never becomes stale. An imported script served with max-age=31536000 under the default mode can therefore stay stale for as long as that header allows. The byte-for-byte comparison described next also reads the stale HTTP-cache copy and sees no change. Either fingerprint imported scripts (importScripts("/sw-lib.4b1e9a.js"), so a new version means a new URL, and so a byte-different sw.js) or register with updateViaCache: "none".
During an update check, if the top-level script is byte-identical, the browser re-fetches every imported script and compares it byte for byte. A change in any of them triggers an update. Chrome added this check in Chrome 78, matching what Firefox had done since Firefox 56 and what Safari already did (Fresher service workers, by default).
The spec's byte comparison covers the top-level script and scripts loaded with importScripts(). For module workers (type: "module"), don't rely on a change to a statically imported module alone to trigger an update. Make sure your build changes the top-level file whenever a dependency changes. Bundlers and Workbox's injected precache manifest do this naturally.
The updateViaCache option¶
| Value | Top-level sw.js | importScripts() / imported modules | When to use |
|---|---|---|---|
"imports" (default) | Always revalidated (no-cache) | HTTP cache may answer while fresh | Most apps. Version imported files by URL |
"all" | HTTP cache may answer while fresh | HTTP cache may answer while fresh | Almost never. It reintroduces the risk the default removed |
"none" | Always revalidated | Always revalidated | Unversioned imported scripts you can't fingerprint |
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
type: "classic",
// Imported scripts in this app are not fingerprinted, so never let the
// HTTP cache answer for them during update checks.
updateViaCache: "none",
});
console.debug("SW registered; updateViaCache =", registration.updateViaCache);
} catch (error) {
// SecurityError: wrong MIME type, scope violation or insecure origin.
// TypeError: script fetch failed, redirect, or a script evaluation error.
console.error("Service worker registration failed:", error);
}
});
}
If you change the value in a later release, the next register() call runs an update. Per the spec's Update algorithm, the registration's updateViaCache switches to the new value even if the scripts turn out to be byte-identical.
Chrome 68 made "imports" the default (Fresher service workers, by default). Before that, Chrome let the HTTP cache answer for sw.js but capped its effective max-age at 24 hours. The "24-hour rule" you'll see in older articles comes from that cap, and it survives in today's spec as the staleness check.
Headers for sw.js¶
Even though modern browsers revalidate sw.js by default, send an explicit policy. It protects older engines, CDNs and any code that fetches the script another way:
Cache-Control: no-cache
Content-Type: text/javascript; charset=utf-8
ETag: "sw-8e1d0c"
no-cache with an ETag turns each update check into a cheap 304. max-age=0 works too. Avoid no-store on sw.js: it removes the 304 optimization and gains nothing. Most importantly, make sure your CDN doesn't cache sw.js beyond a revalidation. A CDN holding an old sw.js for a day blocks every update for a day, no matter what the browser does.
Recommended headers by resource type¶
This policy works for almost every PWA with a build step that fingerprints assets:
| Resource | Cache-Control | Why |
|---|---|---|
/sw.js and other top-level worker scripts | no-cache | Update checks must reach the origin. 304s keep them cheap |
Unversioned importScripts() files | no-cache | Under "imports", the HTTP cache answers for them for their full max-age. An active user's registration never becomes stale, because every revalidation of sw.js resets the 24-hour clock |
/manifest.webmanifest | no-cache | Browsers check the manifest to update installed apps. See App Identity & Updates |
HTML documents (navigations, /, /index.html, /offline.html) | no-cache | Always revalidated, so new deploys are visible immediately, and bfcache stays usable |
Fingerprinted JS, CSS, fonts, images (/assets/*.[hash].*) | public, max-age=31536000, immutable | The URL changes whenever the content changes |
| Unversioned images and icons | public, max-age=86400 or no-cache | Short enough to update. Better yet, fingerprint them |
| Public API responses (same for every user) | public, max-age=0, s-maxage=60 plus ETag | The CDN absorbs load for a minute while browsers revalidate. Put stale-while-revalidate in CDN-Cache-Control rather than Cache-Control, or browsers will serve stale copies too |
| Private API responses | private, no-cache plus ETag | Never stored by shared caches, revalidated by the browser |
| Sensitive responses (tokens, account data) | no-store | Never written to any HTTP cache |
Missing hashed assets (404) | no-store or no-cache | A cached 404 for a chunk can break the app until it expires |
HTML documents¶
HTML is the entry point that references every fingerprinted asset, so it must never be served stale from the HTTP cache. no-cache plus an ETag costs one conditional round trip per navigation. With navigation preload, that round trip runs in parallel with worker startup. If your worker serves HTML from Cache Storage (an app shell, see App Shell Model), the header still matters: it governs the first visit, visits after the worker is evicted, Shift+Reload, and every network fetch the worker makes.
Fingerprinted assets¶
The long-lived, immutable policy is safe only when every change produces a new URL. Two operational rules go with it:
- Never serve HTML for a missing asset. SPA fallbacks (
try_files $uri /index.html, catch-all rewrites) turn a request for a deleted/assets/app.old.jsinto a200HTML response. If that response also getsmax-age=31536000, immutable, the browser caches your HTML as JavaScript for a year. Exclude asset paths from SPA fallbacks and return a real404. - Keep old assets for a while after a deploy. Pages loaded before the deploy, and old service workers serving precached HTML, still request the previous chunk names. Keep at least the previous release's files online, or precache every lazily loaded chunk, so code-split imports don't fail mid-session.
The web app manifest and icons¶
Browsers fetch the manifest when a page links to it, and use it to decide installability and to update installed apps. Send no-cache and the correct Content-Type: application/manifest+json. Icons referenced by the manifest are best fingerprinted too. An installed app's icons are refreshed only through the browser's manifest update process, and serving a changed image under the same URL makes it hard to reason about when users see it.
API responses¶
Pick one owner for API freshness. If the service worker implements network-first or stale-while-revalidate for an endpoint, send private, no-cache (or no-store for sensitive data) so the worker's fetch() actually reaches the server. If you give an API response max-age=300, remember that the worker's "network" path returns the HTTP-cached copy for five minutes. Offline-First Data & Sync discusses keeping API data in IndexedDB rather than Cache Storage.
CDNs and shared caches¶
A CDN adds a third cache between the browser and your origin, and its behavior is often configured separately from your headers. The rules that matter for PWAs:
s-maxagetargets shared caches only.Cache-Control: max-age=0, s-maxage=86400lets the CDN hold a response for a day while browsers revalidate on every use. RFC 9111 givess-maxagethe semantics ofproxy-revalidate, so a shared cache must not serve it stale past that point.- Targeted cache-control headers (RFC 9213) separate CDN policy from browser policy completely.
CDN-Cache-Controlapplies to CDNs that support it. Vendor-specific variants apply to one CDN only (Cloudflare-CDN-Cache-Control,Vercel-CDN-Cache-Control,Netlify-CDN-Cache-Control). Vendor variants usually aren't forwarded to the browser. PlainCDN-Cache-Controlmay be. - Some CDNs rewrite
Cache-Control. Vercel consumess-maxageandstale-while-revalidateand strips them from the response the browser sees when noCDN-Cache-Controlis set. Always check the headers as the browser receives them, not as your origin sends them. - Deploy-time invalidation. Netlify, Vercel, Cloudflare Pages and Firebase Hosting invalidate their CDN caches for static files on each deploy. With your own CDN in front of an origin, purge at least
sw.js, the manifest and HTML on every deploy. Fingerprinted assets don't need purging. - Negative caching. CDNs may cache
404s even when browsers don't. Firebase Hosting, for example, caches404s returned by Cloud Functions and Cloud Run for 10 minutes unless the response carries its own caching headers. A404for a new chunk or forsw.jsduring a botched or half-finished deploy can then persist at the edge after the file appears. Set-CookieandAuthorization. Many CDNs refuse to cache responses withSet-Cookie, or requests withAuthorization. That's good for safety, but it explains unexpected misses.
For a service worker script behind a CDN, the safest policy is Cache-Control: no-cache everywhere. If you want the CDN to absorb update-check traffic, use Cache-Control: no-cache with CDN-Cache-Control: max-age=60 (or a vendor equivalent). The edge then holds sw.js for at most a minute, and a deploy purge makes it immediate.
bfcache and service workers¶
The back/forward cache (bfcache) keeps a complete, frozen page in memory when the user navigates away, and restores it instantly on Back or Forward. It interacts with service workers and HTTP caching in specific ways:
- A bfcache restore doesn't dispatch a fetch event. No navigation request is made, so neither your worker nor the HTTP cache is involved. Listen for
pageshowwithevent.persisted === trueto refresh data after a restore. - Service worker activity can evict a cached page. The
notRestoredReasonsvalues documented on MDN includeserviceworker-added(the page became controlled while cached),serviceworker-claimed(clients.claim()claimed it),serviceworker-postmessage(the worker posted a message to it),serviceworker-version-activated(a new worker version activated) andserviceworker-unregistered. A page cached before a deploy will be evicted when the new worker activates. That's usually what you want. But it also means apostMessage()broadcast that reaches bfcached pages costs each of them its instant restore, so message only the clients that need it. Cache-Control: no-storeon the document affects eligibility. WebKit refuses to cache a main-frame HTTPS document whose response hasno-store(itsBackForwardCachelogs this ashttpsNoStore), and Chrome's documentation warns that other browsers may still block bfcache for such pages. Chrome ran experiments from Chrome 116, gradually increasing the share of page loads, with the rollout planned to reach 100% of users in March and April 2025. It evicts such pages when cookies or other authorization state change, when they use WebSocket, WebTransport or WebRTC, or when afetch()or XHR response hasno-store. It also shortens their bfcache timeout to 3 minutes (from 10). TheAllowBackForwardCacheForCacheControlNoStorePageEnabledenterprise policy can turn this off. The portable advice stays the same: useno-cachefor ordinary HTML, and keepno-storefor pages that really are sensitive.
// Restore handling for pages that show live data.
window.addEventListener("pageshow", (event) => {
if (!event.persisted) return; // Normal load: nothing to do.
// Restored from bfcache: no fetch event fired and no HTML was re-requested.
// Refresh anything time-sensitive and check whether a new worker is waiting.
document.dispatchEvent(new CustomEvent("app:resume"));
navigator.serviceWorker?.getRegistration().then((registration) => registration?.update());
});
// Diagnose why a navigation wasn't restored from bfcache (Chromium 125+).
const [navigation] = performance.getEntriesByType("navigation");
if (navigation?.notRestoredReasons) {
const reasons = navigation.notRestoredReasons.reasons?.map((r) => r.reason) ?? [];
if (reasons.length) console.info("bfcache not used:", reasons);
}
PerformanceNavigationTiming.notRestoredReasons is Chromium-only (Chrome 125). DevTools' Application › Back/forward cache panel runs the same checks interactively. Loading Performance covers bfcache as a performance feature.
Header configuration by server and host¶
The configurations below implement the policy from Recommended headers by resource type for a build that emits fingerprinted files under /assets/, a top-level sw.js, manifest.webmanifest and HTML pages. Adapt the paths to your build output.
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
root /var/www/app/dist;
# nginx sends ETag (mtime + length) and Last-Modified for static files by default.
etag on;
# HTML and SPA routes: always revalidate.
location / {
try_files $uri $uri.html $uri/ /index.html;
add_header Cache-Control "no-cache" always;
}
# Service worker: revalidate on every update check.
location = /sw.js {
add_header Cache-Control "no-cache" always;
# Only needed when the script lives below the scope it controls:
# add_header Service-Worker-Allowed "/" always;
}
location = /manifest.webmanifest {
types { application/manifest+json webmanifest; }
add_header Cache-Control "no-cache" always;
}
# Fingerprinted assets: cache for a year. No SPA fallback here, so a
# missing chunk is a real 404, and no "always", so that 404 does not
# receive the immutable header.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
# The application sets Cache-Control (private, no-cache / no-store).
}
}
add_header directives are inherited from the enclosing level only if the current level defines none. Adding one add_header in a location silently drops every server-level add_header (including security headers) for that location. Repeat the shared headers, move them into an include file, or, on nginx 1.29.3 and later, use add_header_inherit merge;. By default, add_header applies only to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses. always extends it to every status.
# Requires mod_headers. mod_expires is not needed.
AddType application/manifest+json .webmanifest
# ETags from modification time and size (the 2.4 default), identical across
# servers as long as the deploy preserves file timestamps.
FileETag MTime Size
<IfModule mod_headers.c>
# Default for everything, including HTML: always revalidate.
Header set Cache-Control "no-cache"
# Service worker and manifest (explicit, in case the default changes).
<FilesMatch "^(sw\.js|manifest\.webmanifest)$">
Header set Cache-Control "no-cache"
</FilesMatch>
# Fingerprinted assets, successful responses only.
<If "%{REQUEST_URI} =~ m#^/assets/#">
Header set Cache-Control "public, max-age=31536000, immutable" "expr=%{REQUEST_STATUS} == 200"
</If>
# Sensitive endpoints proxied or generated by the application.
# Clear the default (onsuccess) table first: "always" is a separate
# table, and setting the header in both would send it twice.
<If "%{REQUEST_URI} =~ m#^/account/#">
Header unset Cache-Control
Header always set Cache-Control "no-store"
</If>
</IfModule>
# SPA fallback that never rewrites asset requests.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/assets/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
</IfModule>
Header set (the implicit onsuccess table) applies to successful responses. Header always set also applies to error responses and survives internal redirects such as ErrorDocument. The two are separate tables, and the Apache documentation warns that writing the same header into both can produce a duplicated header, which is why the /account/ block unsets the default-table value first. Headers from mod_proxy_fcgi backends land in the always table, so modify or remove them with Header always. The trailing expr= condition requires Apache 2.4.10 or later. FileETag defaults to MTime Size (it was INode MTime Size in 2.3.14 and earlier), and FileETag Digest switches to a content hash that stays identical across servers.
# No catch-all Cache-Control rule: HTML keeps Netlify's default
# (public, max-age=0, must-revalidate), and no two rules below match
# the same path, so no Cache-Control values get concatenated.
/sw.js
Cache-Control: no-cache
/manifest.webmanifest
Content-Type: application/manifest+json
Cache-Control: no-cache
/assets/*
Cache-Control: public, max-age=31536000, immutable
The equivalent in netlify.toml:
[[headers]]
for = "/sw.js"
[headers.values]
Cache-Control = "no-cache"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
By default, Netlify sends Cache-Control: public, max-age=0, must-revalidate to browsers, caches static files at its edge (Netlify-CDN-Cache-Control: public, s-maxage=31536000, must-revalidate), and invalidates that edge cache for the deploy context on every deploy (atomic deploys). For functions, Netlify-CDN-Cache-Control controls the edge separately from the browser. Netlify always passes CDN-Cache-Control and Cache-Control downstream. When you list the same header name several times, Netlify concatenates the values into one comma-separated header, so avoid rules that stack Cache-Control values for one path. _headers rules don't apply to responses from functions or proxied origins, which must set their own headers.
# HTML keeps the platform default (public, max-age=0, must-revalidate
# plus an ETag). Rules don't overlap: Cloudflare joins a header that
# two matching rules set into one comma-separated value.
/sw.js
Cache-Control: no-cache
/manifest.webmanifest
Cache-Control: no-cache
/assets/*
Cache-Control: public, max-age=31536000, immutable
Cloudflare Pages and Workers Static Assets use the same _headers format: up to 100 rules, each line at most 2,000 characters. If two matching rules set the same header, the values are joined with a comma, so a catch-all /* rule with Cache-Control: no-cache plus the /assets/* rule above would send no-cache, public, max-age=31536000, immutable, and no-cache would win. Prefix a header with ! inside a more specific rule to detach a value that a broader rule added. _headers rules don't apply to responses generated by Pages Functions or Worker code, so set headers in code there. Static assets default to Cache-Control: public, max-age=0, must-revalidate (when the request has no Authorization or Range header) with an ETag that's a hash of the file, and Content-Type comes from the file extension. The edge keeps each asset until the next deployment. Cloudflare advises against adding custom Cache Rules on top of Pages, except for immutable, hashed assets.
One Pages default interacts badly with fingerprinted assets: if the project has no top-level 404.html, Pages assumes a single-page app and answers every unmatched path, including a deleted /assets/app.old.js, with the root page. Add a 404.html if you need real 404s for missing assets, and run the "missing asset" check from Verifying caching behavior after every deploy.
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"headers": [
{
"source": "/sw.js",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
},
{
"source": "/manifest.webmanifest",
"headers": [
{ "key": "Content-Type", "value": "application/manifest+json" },
{ "key": "Cache-Control", "value": "no-cache" }
]
},
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
]
}
Vercel's default is cache-control: public, max-age=0, must-revalidate. Static files are cached at the edge for the lifetime of the deployment. Cache-Control headers returned by a function override vercel.json for the same route. For function responses, Vercel-CDN-Cache-Control (Vercel only, never forwarded) and CDN-Cache-Control (forwarded) take precedence over Cache-Control. When only Cache-Control is set, Vercel strips s-maxage before the response reaches the browser.
{
"hosting": {
"public": "dist",
"cleanUrls": true,
"headers": [
{
"source": "**",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
},
{
"source": "/sw.js",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
},
{
"source": "/manifest.webmanifest",
"headers": [
{ "key": "Content-Type", "value": "application/manifest+json" },
{ "key": "Cache-Control", "value": "no-cache" }
]
},
{
"source": "/assets/**",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
],
"rewrites": [{ "source": "!/assets/**", "destination": "/index.html" }]
}
}
Without a headers rule, Firebase Hosting gives static files a one-hour browser cache (the documentation's own example "overrides the default 1 hour browser cache"). That's exactly the positive max-age on unversioned URLs that causes double caching, so the catch-all no-cache rule above isn't optional. Matching header rules are applied in the order they're defined, so the catch-all comes first and the specific rules after it replace its Cache-Control. Header matching runs on the request path before rewrites, which is why SPA routes rewritten to /index.html still get the catch-all's no-cache.
Firebase Hosting clears its CDN cache for static content on every deploy. Dynamic responses from Cloud Functions and Cloud Run default to Cache-Control: private and are cached at the edge only when you send public with max-age or s-maxage. Hosting strips cookies from those requests except the __session cookie, which it forwards and adds to the cache key. It caches their 404s for 10 minutes unless the response sets its own caching headers. source takes glob patterns (including ! negation, as in the rewrite above). Use regex for RE2 regular expressions, which don't support lookahead.
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
const app = express();
const dist = path.join(path.dirname(fileURLToPath(import.meta.url)), "dist");
// Fingerprinted assets: long-lived and immutable. fallthrough: false turns a
// missing chunk into a 404 instead of falling through to the SPA handler.
app.use(
"/assets",
express.static(path.join(dist, "assets"), {
immutable: true,
maxAge: 365 * 24 * 60 * 60 * 1000, // milliseconds -> max-age=31536000
fallthrough: false,
}),
);
// Everything else in dist: revalidate every time.
app.use(
express.static(dist, {
etag: true,
lastModified: true,
setHeaders(res, filePath) {
res.setHeader("Cache-Control", "no-cache");
if (filePath.endsWith(".webmanifest")) {
res.setHeader("Content-Type", "application/manifest+json");
}
},
}),
);
// Private API responses: browser-only, revalidated. Express adds weak ETags
// to res.json()/res.send() bodies, so If-None-Match yields 304s for free.
app.get("/api/me", (req, res) => {
res.set("Cache-Control", "private, no-cache");
res.json({ id: "u_123", name: "Ada" });
});
// SPA fallback for navigations only (the /assets handler above already ended
// asset requests).
app.get("/{*splat}", (req, res) => {
res.set("Cache-Control", "no-cache");
res.sendFile(path.join(dist, "index.html"));
});
app.listen(process.env.PORT ?? 3000);
express.static wraps serve-static: maxAge defaults to 0 (Cache-Control: public, max-age=0), immutable defaults to false, etag and lastModified default to true, and cacheControl: false disables the header entirely. setHeaders(res, path, stat) runs synchronously for each file served. The /{*splat} wildcard (which also matches /) is Express 5 syntax. Express 4 uses "*".
Verifying caching behavior¶
Check headers as the browser receives them, through every layer, after each deploy:
#!/usr/bin/env bash
# Usage: ./check-cache-headers.sh https://app.example.com
set -euo pipefail
ORIGIN="${1:?origin required}"
for path in / /sw.js /manifest.webmanifest /offline.html; do
printf '\n== %s\n' "$path"
curl -sS -o /dev/null -D - --compressed "$ORIGIN$path" \
| grep -iE '^(HTTP/|cache-control|etag|last-modified|age|vary|content-type|cdn-cache-control|x-cache|cf-cache-status|x-vercel-cache)'
done
# A missing fingerprinted asset must be a real 404 without an immutable header.
printf '\n== missing asset\n'
curl -sS -o /dev/null -D - "$ORIGIN/assets/does-not-exist.00000000.js" \
| grep -iE '^(HTTP/|cache-control|content-type)'
# Revalidation must produce a 304.
etag=$(curl -sS -o /dev/null -D - "$ORIGIN/sw.js" | awk 'tolower($1)=="etag:"{print $2}' | tr -d '\r')
printf '\n== conditional sw.js (expect 304)\n'
curl -sS -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $etag" "$ORIGIN/sw.js"
In the browser, Resource Timing reveals which layer served each resource:
const rows = performance.getEntriesByType("resource").map((entry) => ({
name: new URL(entry.name).pathname,
// workerStart > 0: a service worker was running for this request.
viaWorker: entry.workerStart > 0,
// deliveryType "cache": served from the HTTP cache, including 304 revalidations
// (Chrome 117+, Safari 26.4+).
deliveryType: entry.deliveryType ?? "(unsupported)",
// 0 = local cache hit, 300 = revalidated with a 304 (per spec), otherwise downloaded.
transferSize: entry.transferSize,
decodedBodySize: entry.decodedBodySize,
}));
console.table(rows);
In Chromium DevTools, make sure "Disable cache" in the Network panel is unchecked before testing HTTP caching. Use Application › Service workers › "Bypass for network" to test headers without the worker, and remember that Shift+Reload bypasses the worker anyway. Browser DevTools and the Production Checklist have step-by-step procedures.
Common pitfalls¶
- SPA fallback serving HTML for missing assets, combined with an immutable header, caches HTML as JavaScript for a year. Exclude
/assets/from fallbacks and don't add the immutable header to error responses. - A long
max-ageon unversioned files that the service worker precaches. The HTTP cache feeds stale bytes into Cache Storage. Useno-cacheor fingerprint the files, and precache withcache: "reload". - A CDN caching
sw.js. Browsers revalidate the script, but the revalidation is answered by the edge. Every update is delayed by the CDN TTL. - Redirecting
sw.js(for example, adding a trailing slash or forcing a canonical host). The update fetch uses redirect mode"error", so the update fails. no-storeon all HTML to "fix caching". It removes304s, can cost bfcache eligibility, and doesn't stop a service worker from storing the page.- Network-first strategies against HTTP-cacheable APIs. The worker's
fetch()returns the HTTP-cached copy, so "network" is often the disk. Sendno-cachefrom the API or passcache: "no-cache". - Rebuilding requests from URLs (
fetch(event.request.url)) in the worker. That loses the page's cache mode, credentials mode and headers. - nginx
add_headerinheritance, which silently drops server-level headers (security headers included) in anylocationthat adds its own. - Overlapping
_headersrules on Cloudflare or Netlify. A catch-allCache-Control: no-cacheplus an/assets/*rule produces one comma-joined header in whichno-cachewins, and your immutable assets revalidate on every use. - Permanent redirects without
Cache-Control. Chromium caches a301or308with no explicit freshness indefinitely, so a mistaken redirect outlives the fix. - ETags that differ between servers behind a load balancer. Clients bounce between validators and never get a
304. Vary: CookieorVary: User-Agentat the CDN, which disables shared caching. Useprivatefor per-user responses.- Expecting Cache Storage to expire entries. It ignores
Cache-Control. Expire entries yourself. - Setting
Cache-Controlas a request header on cross-origin fetches. It triggers a CORS preflight. Use thecacheoption instead.
Browser support¶
Support data as of September 2026. Check MDN and caniuse for live data.
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
Request.cache (all modes) | ✅ 64 | ✅ 48 (only-if-cached: 50) | ✅ 10.1 |
ServiceWorkerRegistration.updateViaCache | ✅ 68 | ✅ 57 | ✅ 11.1 |
| Byte check of imported scripts on update | ✅ 78 | ✅ 56 | ✅ |
Cache-Control: immutable | ❌ | ✅ 49 (desktop) ⚠️ | ✅ 11 |
Cache-Control: stale-while-revalidate | ✅ 75 | ✅ 68 | ✅ 14 |
Clear-Site-Data ("storage") | ✅ 61 | ✅ 63 | ✅ 17 |
Clear-Site-Data ("cache") | ⚠️ partial since 61 | ✅ 138 | ✅ 17 |
No-Vary-Search in the HTTP cache | ✅ 141 (Android 143) | ✅ 154 | ❌ |
bfcache for Cache-Control: no-store documents | ✅ (rolled out 2025) | ❌ | ❌ |
PerformanceNavigationTiming.notRestoredReasons | ✅ 125 | ❌ | ❌ |
⚠️ immutable: MDN lists Firefox for Android as unsupported, and Chromium's implementation sits behind the disabled-by-default CacheControlImmutable feature. Chrome's Clear-Site-Data: "cache" is marked partial because some requests may still be served from cache until the tab reloads. Edge follows Chromium except where noted (legacy EdgeHTML supported immutable, which Chromium-based Edge dropped).
Further reading¶
On this site
- Cache Storage API: the programmable cache your worker controls
- Caching Strategies: cache-first, network-first, stale-while-revalidate and more
- Precaching & Runtime Caching: versioned precaches and expiration
- Storage Quotas & Persistence: how much Cache Storage you can use, and eviction
- Updating Service Workers: the update flow from the page's side
- Navigation Preload: parallel navigation fetches and their headers
- Handling Fetch Events:
respondWith(),waitUntil()and request routing - Workbox Fundamentals: precache revisions,
cache: "reload"and expiration plugins
External references
- RFC 9111: HTTP Caching
- RFC 9110: HTTP Semantics (conditional requests)
- RFC 5861: stale-while-revalidate and stale-if-error and RFC 8246: immutable
- RFC 9213: Targeted HTTP Cache Control
- Fetch Standard: request cache mode
- Service Worker specification: Update and Soft Update algorithms
- MDN: Cache-Control and MDN: HTTP caching
- Chrome for Developers: Fresher service workers, by default
- Chrome for Developers: bfcache for Cache-Control: no-store pages
- MDN: Monitoring bfcache blocking reasons