Skip to content

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 every fetch() inside the worker.
  • no-cache means "store, but revalidate before every reuse". no-store means "never store". Use no-cache plus an ETag for HTML, sw.js and the manifest. Save no-store for 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-maxage or targeted headers such as CDN-Cache-Control, and make sure every deploy purges sw.js and HTML.
  • bfcache restores skip the service worker entirely. Activating a new worker, clients.claim(), or a postMessage() 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:

  1. 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. Its Cache-Control: max-age=60 header means nothing to Cache Storage. Expiration is your job (Workbox's ExpirationPlugin keeps timestamps in IndexedDB for exactly this reason). The Cache Storage API page covers the API surface.
  2. 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 cache mode on requests, or send Clear-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:

  1. s-maxage=N, for shared caches only (private caches ignore it).
  2. max-age=N.
  3. Expires minus Date.
  4. 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.

A response that is fresh for one hour in any cache
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 ETag or Last-Modified, validation is a cheap conditional request that usually returns 304 Not Modified with no body. This is the right default for HTML documents, sw.js and the web app manifest.
max-age=0, must-revalidate
In practice this is equivalent to no-cache for browsers. The response is stale immediately, and must-revalidate forbids 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 positive max-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 HTTPS no-store documents as ineligible, and you shouldn't count on other engines either.
  • It says nothing about Cache Storage. A service worker can still cache.put() a no-store response. The Cache API doesn't read Cache-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:

Fingerprinted asset response
Cache-Control: public, max-age=31536000, immutable

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:

Public JSON that tolerates brief staleness
Cache-Control: max-age=60, stale-while-revalidate=86400

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:

  • ETag wins over Last-Modified. When a request carries both If-None-Match and If-Modified-Since, the server evaluates If-None-Match and ignores If-Modified-Since. Send an ETag whenever you can. Last-Modified has 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-Match uses weak comparison, so both work for 304s. Weak ETags can't be used for Range requests with If-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 304 updates the stored headers. The cache merges Cache-Control, Expires, ETag, Date and other headers from the 304 into the stored response. You can change a resource's caching policy without changing its body, as long as the server sends the new headers on 304s.
  • ETags must be identical across servers. Behind a load balancer, every server must generate the same ETag for the same bytes. Apache's FileETag default has been MTime Size since 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 a 304. Content-hash ETags (Apache FileETag 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 the no-cache mode, the HTTP cache adds If-None-Match and If-Modified-Since from the stored validators for you. If you add If-None-Match or If-Modified-Since manually with the default mode, the Fetch Standard switches the request to no-store, so the HTTP cache neither answers it nor stores the result. You handle the 304 yourself.

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:

Compressed response
Vary: Accept-Encoding

Rules that matter for PWAs:

  • Vary: Accept-Encoding is normal. Every compressing server should send it, and browsers send stable Accept-Encoding values, so it doesn't fragment the cache.
  • Vary: Cookie or Vary: User-Agent fragments shared caches so badly that caching is effectively disabled. Vercel's CDN refuses to cache responses that vary on Cookie. For personalized responses, use private instead 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 with Vary: * with a TypeError.
  • Cache Storage honors Vary by default. cache.match(request) compares the headers named in the stored response's Vary against the new request. A response stored from a request with Accept: application/json won't match a later request with a different Accept. 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 carry Service-Worker-Navigation-Preload: true. If your server returns something different for them (for example, only the page body), it must send Vary: Service-Worker-Navigation-Preload so the HTTP cache and CDNs don't serve the partial response to normal navigations. See Navigation Preload.
  • No-Vary-Search narrows 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, but cache.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:

  1. 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.
  2. 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 via fetch(), the HTTP cache may have stored it during that fetch. A response from Cache Storage or a synthetic new Response() leaves no trace in the HTTP cache.
  3. fetch(event.request) preserves the request's cache mode. If the page called fetch(url, { cache: "no-store" }), forwarding event.request keeps no-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.
  4. 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.controller is null on such a page. A hard reload is therefore a test of your HTTP headers alone.
  5. HTTP-cache background revalidation bypasses the worker. As described above, stale-while-revalidate revalidation runs with service-workers mode "none".
  6. 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-cache HTML policy, it revalidates. With a long max-age, event.preloadResponse can resolve to a stale stored copy.
  7. 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-cached requires mode: "same-origin". The Request constructor throws a TypeError if you combine only-if-cached with 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 carries If-Modified-Since, If-None-Match, If-Unmodified-Since, If-Match or If-Range, fetch switches it to no-store.
  • Navigation requests can't be re-created as-is. Passing a navigate-mode request plus an init object to new Request() converts its mode to same-origin. That's fine for fetching, but it's why you pass event.request straight through when you don't need to change it.
  • Support. Request.cache is supported in Chrome 64, Firefox 48 (only-if-cached in 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:

sw.js
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();
}
  1. reload skips 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 keep default and may be served from disk. Workbox's precaching does the same: entries with a revision are fetched with cache: "reload", and entries without one use default.
  2. Some Chromium DevTools builds have issued requests with only-if-cached and a no-cors mode. Forwarding one to fetch() throws, so return early and let the browser handle it.
  3. only-if-cached is 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 the navigate-mode request.
  4. no-cache forces a conditional request even when the HTTP cache holds a fresh copy. Without it, a resource served with max-age=3600 would "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 a revision.
  • Serve unversioned files with no-cache so 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:

sw.js (large media precache)
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 response header
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

  1. Fingerprint everything that's long-lived. Only fingerprinted URLs get a long max-age.
  2. Give unversioned URLs no-cache, never a positive max-age, if a service worker precaches them.
  3. In the worker, use cache: "reload" for precache fetches of unversioned URLs and cache: "no-cache" for revalidation fetches.
  4. For large assets the worker manages explicitly, use cache: "no-store" to avoid storing them twice.
  5. 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, sync or notificationclick, 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: script request 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 redirected sw.js fails the update.
  • Cache mode "no-cache" if any of the following is true:
    • the registration's updateViaCache isn'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.

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
register-sw.js
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:

sw.js response headers
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.

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.js into a 200 HTML response. If that response also gets max-age=31536000, immutable, the browser caches your HTML as JavaScript for a year. Exclude asset paths from SPA fallbacks and return a real 404.
  • 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-maxage targets shared caches only. Cache-Control: max-age=0, s-maxage=86400 lets the CDN hold a response for a day while browsers revalidate on every use. RFC 9111 gives s-maxage the semantics of proxy-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-Control applies 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. Plain CDN-Cache-Control may be.
  • Some CDNs rewrite Cache-Control. Vercel consumes s-maxage and stale-while-revalidate and strips them from the response the browser sees when no CDN-Cache-Control is 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, caches 404s returned by Cloud Functions and Cloud Run for 10 minutes unless the response carries its own caching headers. A 404 for a new chunk or for sw.js during a botched or half-finished deploy can then persist at the edge after the file appears.
  • Set-Cookie and Authorization. Many CDNs refuse to cache responses with Set-Cookie, or requests with Authorization. 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 pageshow with event.persisted === true to refresh data after a restore.
  • Service worker activity can evict a cached page. The notRestoredReasons values documented on MDN include serviceworker-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) and serviceworker-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 a postMessage() broadcast that reaches bfcached pages costs each of them its instant restore, so message only the clients that need it.
  • Cache-Control: no-store on the document affects eligibility. WebKit refuses to cache a main-frame HTTPS document whose response has no-store (its BackForwardCache logs this as httpsNoStore), 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 a fetch() or XHR response has no-store. It also shortens their bfcache timeout to 3 minutes (from 10). The AllowBackForwardCacheForCacheControlNoStorePageEnabled enterprise policy can turn this off. The portable advice stays the same: use no-cache for ordinary HTML, and keep no-store for pages that really are sensitive.
bfcache-refresh.js
// 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.

/etc/nginx/conf.d/app.conf
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.

.htaccess (or inside <VirtualHost>)
# 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.

_headers (in the publish directory)
# 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:

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.

_headers (in the build output directory)
# 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.

vercel.json
{
  "$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.

firebase.json
{
  "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.

server.js
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:

check-cache-headers.sh
#!/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:

cache-report.js (paste into the console)
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-age on unversioned files that the service worker precaches. The HTTP cache feeds stale bytes into Cache Storage. Use no-cache or fingerprint the files, and precache with cache: "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-store on all HTML to "fix caching". It removes 304s, 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. Send no-cache from the API or pass cache: "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_header inheritance, which silently drops server-level headers (security headers included) in any location that adds its own.
  • Overlapping _headers rules on Cloudflare or Netlify. A catch-all Cache-Control: no-cache plus an /assets/* rule produces one comma-joined header in which no-cache wins, and your immutable assets revalidate on every use.
  • Permanent redirects without Cache-Control. Chromium caches a 301 or 308 with 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: Cookie or Vary: User-Agent at the CDN, which disables shared caching. Use private for per-user responses.
  • Expecting Cache Storage to expire entries. It ignores Cache-Control. Expire entries yourself.
  • Setting Cache-Control as a request header on cross-origin fetches. It triggers a CORS preflight. Use the cache option 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

External references