WebPublished 4/29/2026

Browser Caching Strategies Explained: Cache-Control, ETag, and Service Worker

How Cache-Control, ETag, and Service Worker split the work of caching, so your site loads fast without leaving users stuck on stale content.

What problem is caching actually solving?

Picture a world with no caching at all. Every time you open a page, the browser re-downloads the HTML, CSS, JavaScript, and images from scratch, even if none of it has changed since five minutes ago. Every round trip costs time, and the server has to redo work it already did. Everyone loses.

Caching exists to fix exactly that: let the browser remember "I already have this, and it's probably still good" and reuse the local copy instead of bothering the server again. But there's a catch — you can optimize for speed or for freshness, rarely both at once. Cache too conservatively and users keep seeing stale content. Cache too aggressively and you lose the whole point of caching.

Cache-Control and ETag are the two languages servers use to tell the browser "here's how long you can trust this" and "here's how to check if it actually changed once that trust runs out." Service Worker takes it a step further — it lets you write the caching logic yourself instead of relying on the browser's defaults.

Cache-Control: deciding whether to even ask the server

Cache-Control is an HTTP response header that tells the browser how long a resource can be cached and who's allowed to cache it. It's the first layer checked: if the browser considers its copy still "fresh," it uses it directly — no request goes out at all.

Here's a common directive:

http
Cache-Control: public, max-age=31536000, immutable
  • max-age=31536000: this resource stays fresh for 31,536,000 seconds — one year. The browser won't re-request it during that window.
  • public: CDNs and proxies can cache this too, not just the browser.
  • immutable: tells the browser this file's content will never change, even if the user hits refresh, so there's no need to revalidate. This is perfect for hashed static assets like main.a1b2c3.js — the moment the content changes, the filename changes with it, so whatever's sitting at the old filename is guaranteed to stay put.

Content that changes often — API responses, HTML itself — needs a completely different approach:

http
Cache-Control: no-cache

The name is misleading. no-cache doesn't mean "don't cache" — it means "cache it, but check with the server before using it every single time." The directive for actually skipping the cache entirely is a different one:

http
Cache-Control: no-store

no-store means no trace gets kept anywhere, not even locally. You'd use this for responses carrying sensitive data, like a page with personal information on it.

In practice, you'll usually see these paired up: hashed static assets (bundled JS, CSS, images) get a long max-age or immutable, while HTML and API responses get no-cache so they're always revalidated. But revalidating doesn't have to mean re-downloading the whole thing — that's where ETag comes in.

ETag: confirming content actually changed after the cache goes stale

When Cache-Control decides a cached response needs revalidating, the browser doesn't just re-download the whole thing blindly. It sends a lightweight request first, essentially asking: "I have version X of this — is that still current?"

That version marker is the ETag (Entity Tag). The server attaches one the first time it responds:

http
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "v3-8f14e45fceea"

The browser stores this ETag alongside the content. Next time it needs to revalidate, it sends the ETag back in an If-None-Match header:

http
GET /api/profile HTTP/1.1
If-None-Match: "v3-8f14e45fceea"

If the server finds nothing has changed, it replies with an almost-empty 304 Not Modified:

http
HTTP/1.1 304 Not Modified
ETag: "v3-8f14e45fceea"

Once the browser sees that 304, it knows it's safe to keep using the local copy — no need to re-download the response body. There's still a round trip, but the payload is tiny compared to fetching the whole thing again, and the bandwidth savings add up fast.

If the content did change, the server sends back a fresh 200 OK with the new body and a new ETag, and the browser stores both.

You'll also run into Last-Modified / If-Modified-Since, which works the same way but compares timestamps instead of a content marker. ETag is usually a hash or version number, which makes it more precise — a file that gets edited and then reverted will have a new timestamp even though the content is identical, and only ETag catches that correctly.

Service Worker: putting the caching logic in your own hands

The first two layers are protocols between the browser and the server, and all you get to tune is the response headers. Service Worker is different — it's a piece of JavaScript that runs in the background, intercepts every network request the page makes, and lets you decide: does this go to the network, to the cache, or both?

This is also why Service Worker is the foundation of offline experiences and PWAs (Progressive Web Apps).

First, register one:

js
// in your page's JavaScript
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
}

Inside sw.js, you intercept the fetch event and decide how to handle each request. Here are the three most common strategies.

Cache First, best for static assets that rarely change:

js
// sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      // use the cache if we have it, otherwise hit the network
      return cached || fetch(event.request)
    }),
  )
})

Network First, for cases where you want the freshest content possible but still need a fallback when the network is down — a news feed, for example:

js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // got a fresh response, update the cache while we're at it
        const clone = response.clone()
        caches.open('v1').then((cache) => cache.put(event.request, clone))
        return response
      })
      .catch(() => caches.match(event.request)), // network failed, fall back to cache
  )
})

Stale-While-Revalidate, the sweet spot for a lot of sites: show the user what you have right away (even if it's slightly stale), quietly fetch a fresh copy in the background, and it'll be ready next time:

js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('v1').then(async (cache) => {
      const cached = await cache.match(event.request)
      const fetchPromise = fetch(event.request).then((response) => {
        cache.put(event.request, response.clone())
        return response
      })
      // return the stale version now; the network response lands in the cache for next time
      return cached || fetchPromise
    }),
  )
})

None of these three is objectively "correct" — which one you reach for depends on whether freshness or availability matters more for that particular resource. And because a Service Worker is just code you write, it's far more flexible than Cache-Control alone — you can apply completely different strategies per route or per file type.

All three layers together: what actually happens on a request

Put all three layers in order, and a single resource request plays out like this:

  1. The browser first checks whether a Service Worker is intercepting the request. If so, whatever strategy you wrote takes over — it might return from cache, hit the network, or something in between. You're in control.
  2. If no Service Worker steps in, the browser checks the HTTP cache — the freshness window set by Cache-Control. Still within max-age? Use the local copy, no request needed.
  3. If the cache has gone stale, the browser sends a request with If-None-Match, letting the server's ETag decide whether the content actually changed. No change means a 304 and you skip re-downloading the body; a real change means a fresh response.

Wrapping up

Cache-Control decides whether the browser trusts its local copy without even asking. ETag gives it a cheap way to double-check once that trust expires, so nothing gets re-downloaded unnecessarily. And Service Worker hands the whole decision back to you, opening the door to offline support and caching logic as custom as you need it to be.