OfflineGENERALBROWSER-SPECIFICNETWORK-SPECIFIC

Caching Strategies

Cache-first, network-first, stale-while-revalidate, cache-only, network-only: five answers, each right for something and each with a characteristic way of going wrong.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

For this particular request, should the cache or the network answer first — and what does the user get when the answer is wrong?

The user intent

Someone opens the app on a train. They expect the interface to appear immediately, yesterday's data to be visible rather than absent, and to be able to tell the difference between the two.

The obvious build

Pick one strategy and apply it to everything. Cache-first, because that is the fast one, and offline support is the point.

Why it breaks

Cache-first on the HTML pins the user to a build. The HTML names every hashed asset, so freezing it freezes the entire application (Content-Hashed Assets).

How it breaks in a real browser
  • Cache-first on the HTML pins the user to a build. The HTML names every hashed asset, so freezing it freezes the entire application (Content-Hashed Assets).
  • Cache-first on an API read shows a balance, a price or a message thread from an unknown point in the past, with nothing on screen saying so.
  • Network-first on a content-hashed bundle wastes a round trip on every load to re-fetch a file whose URL guarantees its contents cannot have changed.
  • Network-first with no timeout on a connected-but-dead network hangs. The user sees nothing, and the cached copy that would have satisfied them is sitting on disk unread.
  • Stale-while-revalidate applied to a checkout total shows a number that changes under the user's cursor a moment after they read it.
  • One strategy for everything means one failure mode for everything, and it will be the wrong one for at least one thing that matters.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Cache-first (cache falling back to network): look in Cache Storage; on a hit, return it and stop. On a miss, fetch, cache, return. Latency is a disk read. Freshness is whatever you last wrote.
  • Network-first (network falling back to cache): fetch; on success, update the cache and return; on failure or timeout, return the cached copy. Freshness is maximal when online; latency is a round trip.
  • Stale-while-revalidate: return the cached copy immediately *and* fetch in the background to update the cache for next time. The user sees old data now and new data on the next request, not this one (Stale-While-Revalidate).
  • Cache-only: never touch the network. Correct for precached shell assets that are guaranteed present because install put them there.
  • Network-only: never touch the cache. Correct for mutations, authentication, analytics and anything where a stale answer is a wrong answer.
  • Precache is what you fill during install: a fixed, build-generated list of URLs that must exist for the app to start at all. It is complete before the worker activates and it is versioned with the worker.
  • Runtime cache is what accumulates as the user browses: pages, images, API responses. It is unbounded unless you bound it, and it needs an expiry policy you write yourself, because Cache Storage has none (Cache Storage).
  • Cache Storage has no notion of freshness. Cache-Control, ETag and max-age mean nothing to it — a Response stays until your code deletes it (Browser HTTP Caching).

What this makes the browser do

And which of it is avoidable.

  • A caches.match() is a lookup in a disk-backed store. It is fast relative to a network round trip and not free relative to memory, and it scales with the number of entries when you match without a specific cache name.
  • Every runtime cache write is a disk write, competing with the rest of the origin's storage budget.
  • Stale-while-revalidate doubles the request count for revalidated resources: the user gets one response and the network carries two.
  • Precaching at install downloads the whole shell in one burst, on whatever connection the user has at that moment — including a metered one.
  • Unbounded runtime caches grow until the origin hits its quota and the browser starts evicting, potentially taking the precache with it.

Five strategies, and how each one fails

Read the failure column first. Choosing a caching strategy is choosing which failure you are willing to ship, because every one of these is correct for something and catastrophic for something else.

The second column — what answers first — is the only mechanical difference between them. Everything else in the row follows from it.

StrategyAnswers firstRight forFails byOffline
Cache-firstCache, network only on missContent-hashed assets, fonts, precached shell — anything whose URL changes when its bytes doServing a frozen copy forever when the URL is not immutable; the classic bricked buildWorks
Network-firstNetwork, cache on failure or timeoutNavigations and API reads where staleness is visible to the userHanging on a connected-but-dead network if there is no timeout; a round trip on every loadWorks, stale
Stale-while-revalidateCache, plus a background fetch that updates itContent that is useful when slightly old and cheap to correct: lists, avatars, article bodiesThe user acts on the old value; the update lands and moves what they were readingWorks, stale
Cache-onlyCache, and nothing elsePrecached shell and the offline fallback page — things install guarantees existA miss is a hard failure, so an evicted cache breaks the app with no fallbackWorks
Network-onlyNetwork, never the cacheMutations, auth, payments, analytics — anywhere a stale answer is a wrong answerNothing at all when offline, which is correct but must be handled by the UI (The Offline Mutation Queue)Fails
Cache Storage after a deploy, one user:

  shell-v7          <- precache, filled at install, cache-only
    /                 /offline.html   /app.css   /app.js

  static-v7         <- runtime, cache-first (URLs are hashed)
    /assets/main.a1b2.js
    /assets/vendor.9f3c.js
    /fonts/inter.woff2

  pages-v7          <- runtime, network-first, max 30 entries
    /orders   /orders/8812   /settings

  api-v7            <- runtime, stale-while-revalidate, max age 1 day
    /api/orders?page=1
    /api/profile

  (no cache)        <- network-only
    POST /api/orders   /api/session   /analytics

Choosing, per request kind

The decision is not "which strategy does this app use". It is "which strategy does this *kind of request* use", answered five times. A worker whose fetch handler branches on request.destination and request.mode is expressing exactly that.

The criteria are all versions of one question: what does the user lose if this answer is out of date? For a hashed asset, nothing — the URL guarantees the bytes. For an order total, money.

What should answer this request?

A request arrives at the worker. What does the user lose if the answer is stale, and what do they lose if there is no answer at all?

Cache-first

when The URL is immutable — content-hashed bundles, versioned fonts, precached shell files. Staleness is impossible by construction.

cost If the URL is not actually immutable, you have pinned that user to one version with no recovery short of clearing site data.

Network-first (with timeout)

when Navigations, and reads where the user would notice yesterday's answer. You want current data but you would rather show something old than nothing.

cost A round trip on every request, and a timeout you have to choose — too short wastes the network, too long is a blank screen.

Stale-while-revalidate

when Content that is useful slightly old, viewed repeatedly, and cheap to update: lists, profiles, dashboards, images.

cost Two requests for one response, and a UI that must handle content changing after it has been read (Stale-While-Revalidate).

Cache-only

when Assets install guaranteed to be there: the shell, the offline page. Deliberately never touching the network.

cost An eviction becomes a hard failure with no fallback path, so it needs a cache the activate step verified.

Network-only

when Mutations, authentication, payment, analytics — anything where returning an old answer would be wrong rather than merely dated.

cost Offline means failure, so the UI owes the user a queue and an honest explanation instead of an error (The Offline Mutation Queue).

Precache and runtime cache are different objects

SIMPLIFIEDTrimming by insertion order is a teaching version; real eviction policies track last-access time in IndexedDB alongside the cache, because Cache Storage does not record when an entry was last read and cache.keys() order should not be relied on as a strict LRU.

The precache is a contract: this exact list of URLs exists before the worker activates, it is generated by the build, and it is versioned with the worker so activate can delete the previous one wholesale. Its failure mode is a bad manifest.

The runtime cache is a population: it grows as the user browses, contains URLs nobody enumerated, and has no natural end. Its failure mode is unbounded growth. They deserve different cache names, different strategies and different cleanup code, and conflating them produces a cache you can neither version nor bound.

Bounding a runtime cache
Grows until the quota says no
async function cacheFirst(req) {
  const hit = await caches.match(req)
  if (hit) return hit
  const res = await fetch(req)
  const cache = await caches.open('images')
  await cache.put(req, res.clone())   // forever
  return res
}
Bounded, and refuses to cache errors
const MAX = 60

async function cacheFirst(req) {
  const cache = await caches.open('images-v7')
  const hit = await cache.match(req)
  if (hit) return hit

  const res = await fetch(req)
  if (!res.ok) return res            // never cache an error

  await cache.put(req, res.clone())
  const keys = await cache.keys()    // FIFO trim, oldest first
  if (keys.length > MAX) {
    await Promise.all(keys.slice(0, keys.length - MAX).map((k) => cache.delete(k)))
  }
  return res
}

Cache Storage has no expiry and no size limit of its own: the only bound is the origin quota, and hitting it makes writes throw inside handlers that had no rejection path — surfacing as unrelated request failures. The res.ok check matters for the same reason: without it a single 500 is cached and served as the asset until something deletes it.

How to build it

Most important first.

  • Choose per request kind. Navigations, hashed assets, unhashed assets, API reads and API writes are five different decisions and there is no reason for them to agree.
  • Cache-first belongs to immutable URLs. If the URL changes whenever the bytes change, cache-first has no staleness risk at all — that is the whole argument for content hashing (Content-Hashed Assets).
  • Network-first belongs to navigations and to anything whose staleness the user would notice, with a timeout so the cached copy is reachable on a hanging connection.
  • Stale-while-revalidate belongs to content that is useful when slightly old and cheap to correct: avatars, lists, dashboards, article bodies. Never to a number someone is about to act on.
  • Bound every runtime cache: a maximum entry count, a maximum age, or both, enforced by your code because nothing else will.
  • Generate the precache manifest from the build, never by hand. A hand-written list drifts, and a single missing URL fails the whole install (The Service Worker Lifecycle).
  • Whatever you serve stale, mark it in the response or in the app state so the UI can say so. A strategy that hides its own staleness makes the UX lesson impossible (Offline UX).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Content served from cache and content served from the network are indistinguishable to assistive technology unless the page says which it is. Stale data with no announcement is a silent correctness problem for anyone not watching a status pill (Offline UX).
  • When a stale-while-revalidate update replaces what is on screen, announce it politely and do not move focus. A screen-reader user mid-sentence should not be interrupted, and a magnifier user should not lose their place (Live Regions and Announcement).
  • Do not let a background revalidation shift layout under a pointer or a switch target. Reserve the space the updated content will occupy.
  • An offline fallback page reached via cache-only must be fully operable: a heading, a landmark, a real retry button, and no reliance on a script that is not itself cached.

What can go wrong

Failure modes
  • The frozen build: cache-first HTML, no revalidation, a user who never clears storage.
  • The hanging load: network-first without a timeout on a captive-portal or degraded connection.
  • The flicker: stale-while-revalidate on a view that re-renders when the background update lands, moving content the user was reading (Visual Stability).
  • The quota wall: an unbounded image runtime cache fills the origin's budget, cache writes start throwing, and the failure surfaces as unrelated request errors.
  • The eviction: the browser reclaims storage under pressure and the "offline-capable" app has nothing cached at all on the day it is needed.
  • The cached error: a 500 or a redirect written into the cache and served happily for days because nothing checked response.ok.
  • The mitigation failing: an expiry policy that runs only when the worker happens to be awake, so a cache stays over its limit for as long as the user does not visit.
What can arrive out of order
  • Stale-while-revalidate is a race by construction: the cached response and the background update both resolve, and the UI must decide whether the later one replaces what the user is already reading (Out-of-Order Responses).
  • Two tabs revalidating the same URL write to the same cache entry concurrently; the last write wins and it is not necessarily the newest response.
  • A network-first timeout racing the real response: the cached copy is served, then the network response arrives and updates the cache — so the next read differs from the one just shown for reasons the user cannot see.
  • A runtime cache write racing an activate cleanup or a quota eviction.
Security
  • Cache Storage is per-origin and shared by every user of that browser profile. Anything user-specific written there outlives the session unless you delete it (Storage Security and Durability).
  • Clear caches on logout. A cached account page served after sign-out is a session-boundary failure even though no token was involved (Session Expiry and the Refresh Race).
  • Never cache a response you cannot inspect. Opaque cross-origin responses hide their status, so you can cache an error or an attacker-controlled redirect target without knowing.
  • A cached response bypasses every server-side authorization check on subsequent reads. The server remains the only authority; the cache is a convenience that must never be the thing deciding what a user may see (What the Frontend Is Responsible For in Auth).
Misreads
  • "Stale-while-revalidate gives the user fresh data." It gives them stale data now. The fresh copy arrives for the *next* read, unless you explicitly re-render when it lands.
  • "Cache Storage respects Cache-Control." It does not. There is no expiry unless you implement one.
  • "Cache-first is the fast strategy." It is the *available* strategy. On a good connection with small payloads the network can win, and cache-first is chosen for resilience, not speed.
  • "Precaching everything makes the app fully offline." It makes the shell offline. Data is a separate problem with a separate strategy and a separate staleness story.
  • "A cache hit is a success." A cache hit on something the user needed to be current is a failure that reports as a success.

Measuring it, and what changes in the field

How you would see this
  • The Network panel shows which responses came from the worker and, for stale-while-revalidate, the second background request alongside the one the page consumed.
  • The Application panel lists Cache Storage buckets and their contents — the place to confirm an entry count is actually bounded.
  • navigator.storage.estimate() reports usage and quota for the origin, which is how you find a runtime cache that has quietly grown (Choosing Browser Storage).
  • In the field, track the share of responses served from cache versus network per request kind. A cache hit rate that is high on API reads is a staleness report, not a win (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a fast network with small responses, cache-first can be slower than the network once the disk read and the worker start are counted. The advantage is largest on high-latency links (Reading a Network Waterfall).
  • On a metered connection, stale-while-revalidate doubles the data for content the user may not look at again.
  • On a device under storage pressure, everything here is best-effort; navigator.storage.persist() asks for durable storage and the browser may say no.
  • On a large dataset — thousands of cached images or API responses — matching and eviction costs stop being negligible, and a per-cache bound matters more than the strategy choice.
What this costs
  • Every strategy trades freshness against latency and availability. There is no option that is fast, current and available offline at the same time; you are choosing which one to give up per request kind.
  • Precaching makes the next visit instant and makes every deploy a bulk download for every user.
  • Bounding runtime caches protects the quota and costs you the long tail of content that would have been available offline.
  • Marking staleness in the UI is honest and adds a state to every view that renders cached data.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe Cache Storage API and the five strategies are specified behaviour and work the same across engines; what differs is quota and eviction policy, not semantics.
  • BROWSER-SPECIFICStorage quota and eviction differ substantially: Chromium grants a share of available disk and evicts least-recently-used origins under pressure, Safari has historically applied a shorter eviction horizon for sites without a home-screen install, and navigator.storage.persist() is granted on different criteria in each — so "cached" never means "guaranteed present".
  • NETWORK-SPECIFICThe benefit of cache-first scales with round-trip time: on a high-latency mobile link it dominates, while on a fast local connection with small responses the worker start plus disk read can exceed the network path it replaced.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — a browser cache is a replica with no coordination and no invalidation channel, so every strategy here is a client-side choice of consistency model.