Skip to content

Caching and Offline in Progressive Web Apps

A Progressive Web App works offline because it controls where every byte comes from: a service worker intercepts requests and answers them from Cache Storage, IndexedDB or the Origin Private File System instead of the network. Those programmable stores sit alongside browser-managed caches you do not control directly (the per-document memory cache, the back/forward cache and the HTTP cache), and each layer has different lifetimes, partitioning rules, quotas and failure modes. This page maps every layer, shows how they stack on a single request, and tells you which one to use for each kind of data before you dive into the detailed pages of this section.

Key takeaways

  • There are two kinds of layer: browser-managed caches (memory cache, back/forward cache, HTTP cache) that follow HTTP rules and heuristics, and script-managed storage (Cache Storage, IndexedDB, OPFS, Web Storage) that keeps exactly what you put there until you delete it or the browser evicts the whole origin.
  • Cache Storage stores full Request/Response pairs and is the natural home for anything you will hand to event.respondWith(). It ignores Cache-Control, so freshness and expiration are your job.
  • IndexedDB is the source of truth for structured application data and outbox queues. OPFS is for large files and high-throughput binary I/O, including SQLite databases.
  • localStorage is synchronous, string-only, capped at about 5 MiB and unavailable in workers, so a service worker cannot read it. Keep it for tiny UI preferences.
  • Cache Storage, IndexedDB, OPFS and service worker registrations share one quota per storage key, and eviction under storage pressure removes all of that data at once unless you have persistent storage.
  • A fetch() from a service worker still goes through the HTTP cache, so the two layers interact. Stale HTTP-cached files that end up in a precache are one of the most common offline bugs.

The caching and storage layers at a glance

Browsers expose at least eight places where a response or a piece of application state can live. The table summarizes who controls each one, how long data survives, and what it is good for. The subsections that follow explain each layer in more depth. Where a layer has its own page in this section, the subsection is a summary and links there.

Layer Controlled by Stores Lifetime Readable from workers Honors HTTP caching headers
Memory cache Browser (per document) Decoded images, preloads, recently used subresources Current document No (not an API) Mostly no
Back/forward cache Browser Entire page snapshot incl. JS heap Minutes, in memory No (not an API) no-store affects eligibility
HTTP cache Server headers + browser HTTP responses Until evicted or expired Indirectly via fetch() Yes, per RFC 9111
Cache Storage Your script Request → Response pairs Until deleted or origin evicted Yes No
IndexedDB Your script Structured-clonable values, Blobs Until deleted or origin evicted Yes Not applicable
OPFS Your script Files and directories Until deleted or origin evicted Yes (sync handles in dedicated workers) Not applicable
localStorage / sessionStorage Your script String key/value pairs Persistent / per tab session No Not applicable
Cookies Server and script Small strings sent with requests Per Expires/Max-Age Only via the Cookie Store API, where supported Not applicable

Memory cache: per-document reuse you do not control

Every engine keeps recently used subresources in memory for the lifetime of a document, so that the second <img src="/logo.svg"> on a page or a script fetched by <link rel="preload"> does not trigger a second fetch. The HTML Standard describes parts of this behavior explicitly (the list of available images and the map of preloaded resources that a matching fetch consumes), and engines add their own layers such as Blink's in-memory resource cache.

Two properties matter for PWAs. First, a hit in this layer is served before the request reaches the service worker, so it does not dispatch a fetch event. The original load already went through your service worker, so this rarely causes bugs, but it explains why a fetch handler can see fewer requests than the page makes. Second, the memory cache does not apply HTTP freshness rules the way the HTTP cache does. Yoav Weiss's article A Tale of Four Caches (linked below) remains the clearest description of this layer, although the fourth cache it describes, the HTTP/2 push cache, is gone: Chrome disabled HTTP/2 Server Push by default in Chrome 106.

Back/forward cache: whole-page snapshots

The back/forward cache (bfcache) keeps a complete, frozen copy of a page (DOM, JavaScript heap, scroll position) in memory after the user navigates away, and restores it instantly on Back or Forward. It is not an API and it caches no individual responses, but it is the fastest "cache" a PWA can hit, and it interacts with your storage code:

  • Restores fire pageshow with event.persisted === true instead of a fresh load. Any data you read from IndexedDB or Cache Storage at startup can be stale after a restore, so refresh it in a pageshow handler.
  • Use pagehide instead of unload to flush state. An unload listener can make a page ineligible for bfcache in some browsers, and it does not run reliably on mobile.
  • Cache-Control: no-store on the HTML historically made pages ineligible. Chrome finished rolling out bfcache for no-store pages to all users in March and April 2025. It evicts such pages when cookies or other authentication state change and keeps them for 3 minutes rather than the usual 10.
  • Open connections (WebSocket, WebRTC) and certain in-flight work can still block the cache. Chromium's notRestoredReasons on the navigation timing entry (Chrome 125 and later) tells you why a page was not restored.

Details and measurement belong to the performance section: see Loading Performance and Measuring Performance.

HTTP cache: the browser's network cache

The HTTP cache stores responses according to Cache-Control, Expires, ETag, Last-Modified and Vary, as defined by RFC 9111. It is shared by every request the browser makes for your origin, including fetch() calls made inside your service worker and the navigation preload request. It is also partitioned: Chrome keys it by top-level site and frame site since Chrome 86, Firefox partitions it by top-level site since Firefox 85, and Safari has long partitioned by top-level site. A CDN-hosted library cached on one site is therefore not reused on another.

The No-Vary-Search response header lets a response declare which query parameters do not change its content, so /list?utm_source=a and /list can share one HTTP cache entry. The HTTP cache honors it in Chrome 141 and later and in Firefox 154 and later. Safari does not support it as of September 2026. Cache Storage has no equivalent beyond the all-or-nothing ignoreSearch option, which is one of many semantic differences covered in HTTP Caching & Service Workers.

Service worker and Cache Storage: the programmable layer

Cache Storage (self.caches) is an origin-scoped set of named caches, each an ordered list of Request/Response pairs. It is exposed in windows, dedicated and shared workers, and service workers, only in secure contexts. The spec is explicit that these caches are not part of the HTTP cache: entries are never updated, revalidated or expired unless your code does it. A service worker combines this store with the fetch event to implement cache-first, network-first and stale-while-revalidate behavior.

Cache Storage matches on URL, method and Vary. It can hold opaque cross-origin responses, which Chromium pads for quota purposes by a pseudo-random amount between 0 and about 14 MiB each (about 7 MiB on average). addAll() is atomic, and it rejects on any non-2xx response. The full API surface, the matching algorithm, and working code for expiration, LRU trimming and size measurement are on the Cache Storage API page. How to use it for specific request types is covered in Caching Strategies and Precaching & Runtime Caching.

Chromium (since Chrome 123) and Safari (since Safari 27) also support the Service Worker Static Routing API, which lets a worker declare at install time that certain requests should be answered with source: "cache" (optionally a specific cacheName) without starting the worker at all. See Static Routing API.

IndexedDB: structured data and the offline source of truth

IndexedDB is a transactional object store with indexes, cursors and key ranges, available in windows and all worker types, including service workers. It stores anything the structured clone algorithm supports, including Blob, File, ArrayBuffer, Map and Date. For an offline-first app it is where the canonical copy of user data lives: records synced from the server, drafts, and the outbox of mutations waiting for Background Sync. It is also the right place for metadata about your Cache Storage entries (insertion time, last access, byte size), because Cache Storage itself cannot record any. See IndexedDB and Offline-First Data & Sync.

Origin Private File System: files and high-throughput binary I/O

The Origin Private File System (OPFS), reached through navigator.storage.getDirectory(), is a sandboxed file system that is invisible to the user and scoped to your origin. It is supported in all three engines (Chrome 86, Firefox 111, Safari 15.2 for getDirectory()). In dedicated workers, FileSystemFileHandle.createSyncAccessHandle() gives synchronous, in-place reads and writes, which is what makes SQLite-in-WebAssembly and other database engines practical in the browser. Use it for large media, document files the app edits in place, and database files. OPFS counts against the same quota as Cache Storage and IndexedDB. See Origin Private File System. For files the user can see and pick, use the separate File System Access API.

localStorage and sessionStorage: why they are not offline storage

Web Storage looks convenient, but it is a poor fit for PWA data:

  • It is synchronous. Every getItem/setItem can block the main thread on disk I/O, and the first access may load the whole store.
  • It is window-only. localStorage does not exist in WorkerGlobalScope, so your service worker cannot read a token or a setting you put there.
  • It stores strings only, and MDN documents the limit as 5 MiB for localStorage plus 5 MiB for sessionStorage per origin. Exceeding it throws QuotaExceededError.
  • It has no transactions, so concurrent tabs can overwrite each other's read-modify-write updates. The storage event fires only in other documents.
  • It is subject to the same eviction rules as other script-writable storage, including Safari's seven-day cap described below.

Keep localStorage for tiny, non-critical UI preferences such as a theme or a dismissed banner, and read those only on the main thread. Anything the service worker needs goes in IndexedDB.

Cookies are not an application data store

Cookies are sent with every matching request, are limited to about 4 KB each, and are partitioned or blocked in third-party contexts. They are the right tool for session identifiers and server-read flags, not for offline data. Note that Cache Storage does not know about cookies at all: two users who share a device and the same cached URL get the same cached response. Clear user-specific caches on logout.

How the layers stack on a single request

The sequence below follows one subresource request from a page controlled by a service worker. It includes the static router and both hit and miss paths through Cache Storage and the HTTP cache. It also shows the offline branch that makes the request succeed without a network.

sequenceDiagram
    autonumber
    participant Page
    participant Mem as Memory cache
    participant Router as Static router
    participant SW as Fetch handler
    participant CS as Cache Storage
    participant HTTP as HTTP cache
    participant Net as Network
    Page->>Mem: GET /app.js
    alt reused by this document
        Mem-->>Page: in-memory response, no fetch event
    else not in memory
        Page->>Router: request enters the service worker layer
        alt route source is cache
            Router->>CS: lookup without starting the worker
            CS-->>Page: cached response
        else fetch-event route or no routes
            Router->>SW: dispatch fetch event
            SW->>CS: caches.match(request)
            alt cache hit
                CS-->>SW: Response
                SW-->>Page: respondWith(cached)
            else cache miss
                SW->>HTTP: fetch(request)
                alt fresh in HTTP cache
                    HTTP-->>SW: stored response
                else stale or absent
                    HTTP->>Net: conditional or full request
                    alt online
                        Net-->>HTTP: 200 or 304
                        HTTP-->>SW: response
                        SW->>CS: cache.put(request, clone)
                    else offline
                        Net--xHTTP: network error
                        HTTP--xSW: fetch() rejects with TypeError
                        SW->>CS: match offline fallback
                    end
                end
                SW-->>Page: response or fallback
            end
        end
    end

Several details in this flow cause real-world bugs:

  1. Memory cache hits skip the fetch handler. Instrumentation that counts requests in the service worker undercounts repeat subresource loads within a document.
  2. The static router runs first. With addRoutes(), a matching cache route answers from Cache Storage without waking the worker. If the entry is missing, the browser goes straight to the network, not to your fetch handler, so route only URLs you know are cached.
  3. Cache lookups happen in your code, in your order. A caches.match() without a cacheName walks every cache in creation order and returns the first hit. Two caches holding different versions of the same URL can therefore serve the older one.
  4. Your fetch() goes through the HTTP cache. If /app.js is served with Cache-Control: max-age=31536000 but is not revisioned in its URL, a precache step can copy a year-old HTTP-cached copy into Cache Storage. Request it with cache: "reload" or cache: "no-cache", or use hashed file names. See HTTP Caching & Service Workers.
  5. Offline is a rejected promise, not a status code. When the network is unreachable, fetch() rejects with a TypeError. A 404 or 500 from a reachable server resolves normally, and your strategy must decide whether an error response should be cached or replaced by a fallback. See Offline UX & Fallbacks.
  6. Navigations are special. The HTML request for a page uses mode: "navigate" and redirect: "manual". A cached response that was the result of a redirect (response.redirected === true) triggers a network error when used for a navigation. Cache Storage API shows how to strip that flag.

What happens in the service worker's absence

Before a service worker has installed and claimed the page (the first visit), or when the user force-reloads with Shift and the reload button, requests bypass the service worker completely and only the memory cache and HTTP cache apply. This is why a PWA's first load must be fast on its own merits. The service worker only improves the second and subsequent loads, and it can only serve offline what it cached during an earlier online visit. The mechanics of that first install are in Lifecycle and Registration & Scope.

Where double caching happens

Because the service worker's network requests traverse the HTTP cache, the same bytes can exist in both the HTTP cache and Cache Storage. This is usually harmless and sometimes useful: HTTP revalidation can make a network-first strategy cheap with 304 Not Modified. It becomes a problem in three situations:

  • Precaching unversioned URLs. The precache copies whatever the HTTP cache hands back, which may be stale.
  • Quota pressure. The HTTP cache does not count against your origin's storage quota, but Cache Storage does. Caching huge media both ways wastes device storage.
  • Debugging. Chromium DevTools may show "(disk cache)" for the request made by the worker and "(ServiceWorker)" for the page's view of the same request. Both are true.

Choosing a storage layer by data type

Use this table as the default mapping. The right answer for your app may differ, but deviations should be deliberate.

Data Recommended layer Why Watch out for
App shell HTML, CSS, JS with hashed file names Cache Storage (precache) + HTTP cache immutable Served directly to respondWith(); versioned by build Clean old caches in activate
Unhashed HTML documents Cache Storage (network-first or stale-while-revalidate) Needs an offline copy but also freshness redirected responses for navigations
Offline fallback page and its assets Cache Storage (precache) Must exist before the first offline navigation Precache it at install, not lazily
Web fonts, icons, images Cache Storage (cache-first with expiration) Large, rarely change Opaque cross-origin responses inflate quota
API JSON responses you render directly Cache Storage or IndexedDB Cache Storage if you replay the response; IndexedDB if you query or merge it Cached JSON has no expiry of its own
Normalized application records (messages, tasks, products) IndexedDB Indexes, transactions, partial updates Schema migrations via onupgradeneeded
Pending user mutations (outbox) IndexedDB Survives restarts and is readable by Background Sync Idempotency keys on replay
Drafts and form state IndexedDB (debounced writes) Crash-safe, worker-readable Write on pagehide, not unload
User files, audio, video to play offline OPFS or Cache Storage OPFS for random access and editing; Cache Storage if served by URL Range requests need 206 responses built by hand
SQLite or other embedded database files OPFS (sync access handle in a worker) In-place writes, high throughput Dedicated-worker-only sync API
Metadata about cached responses (timestamps, sizes) IndexedDB Cache Storage cannot store it Keep both in sync when deleting
Theme, locale, dismissed banners localStorage Synchronous read before first paint Window-only; 5 MiB; strings only
Per-tab ephemeral UI state sessionStorage or memory Scoped to the tab Lost on tab close
Session identifiers, auth for server rendering Cookies (HttpOnly, Secure, SameSite) Sent automatically to the server Not visible to Cache Storage matching
Encryption keys IndexedDB holding non-extractable CryptoKey objects Structured clone preserves CryptoKey without exposing key material Evicted with the rest of the origin

The same logic as a decision tree:

flowchart TD
    A[New piece of data] --> B{"Will you serve it as the response to a request?"}
    B -- Yes --> C{"Is plain HTTP caching enough, with no offline guarantee?"}
    C -- Yes --> D[HTTP cache via Cache-Control]
    C -- No --> E[Cache Storage via the service worker]
    B -- No --> F{"Structured records you query or update?"}
    F -- Yes --> G[IndexedDB]
    F -- No --> H{"Large file or random-access binary data?"}
    H -- Yes --> I[OPFS]
    H -- No --> J{"Tiny setting read synchronously on the main thread?"}
    J -- Yes --> K[localStorage]
    J -- No --> G

Shared quota, eviction and persistence

Cache Storage, IndexedDB, OPFS and service worker registrations all live in the same storage bucket for your storage key, as defined by the WHATWG Storage Standard. They share one quota, and they are evicted together. The HTTP cache and cookies are separate and do not count. The headline numbers as of September 2026, all documented by MDN or WebKit:

Engine Per-origin quota Eviction
Chromium (Chrome, Edge) Up to 60% of total disk LRU by origin under storage pressure; persistent origins exempt
Firefox Best-effort: 10% of disk or 10 GiB group limit, whichever is smaller. Persistent: 50% of disk, up to 8 TiB LRU by origin; persistent origins exempt
Safari 17+ (macOS 14, iOS 17 and later) Browser apps and Home Screen web apps: about 60% of disk. WKWebView apps: about 15% LRU by origin; plus deletion of script-writable storage after 7 days of Safari use without interaction (not for Home Screen apps)

Four consequences for offline design:

  • Eviction is all-or-nothing per origin. Browsers delete every store together so that an app never sees IndexedDB records pointing at Cache Storage entries that no longer exist. Your app must still handle waking up with nothing cached, because that is what eviction looks like.
  • Ask for persistence when it matters. navigator.storage.persist() exempts the bucket from pressure-based eviction when granted. Chromium and Safari decide without a prompt based on engagement signals such as installation. Firefox shows a permission prompt.
  • Safari's seven-day cap applies to browser tabs, not installed apps. WebKit deletes all script-writable storage, including service worker registrations and Cache Storage, after seven days of Safari use without user interaction on the site. Home Screen web apps keep their own usage counter and are not expected to lose first-party data.
  • Third-party iframes get partitioned storage. Chrome partitions Cache Storage, IndexedDB and service workers by top-level site in third-party contexts since Chrome 115, and the other engines partition as well. An embedded widget cannot share an offline cache with its first-party site.

navigator.storage.estimate() returns an approximate usage and quota (Safari since 17). Chromium adds a non-standard usageDetails breakdown. Chromium also ships the Storage Buckets API (Chrome 122), which lets you create separately evictable buckets with their own persistence and expiry. Chrome's documentation states that its implementation covers IndexedDB only, although MDN's compatibility data lists a caches attribute on buckets, so feature-detect before using a bucket for Cache Storage. Full detail, including private-browsing limits and quota error handling, is on Storage Quotas & Persistence. The privacy side of partitioning is on Privacy & Storage Partitioning.

Clearing storage from the server

The Clear-Site-Data response header's "cache" directive targets the HTTP cache and other browser-internal caches, which depending on the browser include prerendered pages, bfcache entries and script caches. Cache Storage is not a browser cache in that sense: it is origin storage that lives in the storage bucket, and it is cleared together with IndexedDB, Web Storage and service worker registrations by the "storage" directive. Send Clear-Site-Data: "cache", "storage" (or "*") from a logout endpoint when cached responses may contain personal data, and delete per-user caches from script as well so that the cleanup does not depend on the header alone.

Offline support is a spectrum

"Works offline" can mean very different amounts of engineering. It helps to pick a level deliberately, because each level implies a different mix of the layers above:

  1. Custom offline page. Precache one fallback HTML page and its assets, and return it when a navigation's fetch() rejects. This needs Cache Storage and a dozen lines of service worker code. It is the minimum that stops the browser's offline error page from appearing inside an installed app.
  2. Cached shell, network content. Precache the app shell and runtime-cache recently viewed pages or API responses. Users can reopen what they already saw. Cache Storage with runtime strategies and expiration covers this level.
  3. Offline reading of chosen content. Let users explicitly save articles, maps or media. Window code writes those entries into a dedicated cache or into OPFS, and a quota-aware UI shows how much space they use.
  4. Offline-first read and write. The UI reads from IndexedDB, and writes go to a local outbox that syncs when connectivity returns. The network is an optimization. This level needs IndexedDB, conflict resolution, and usually Background Sync. See Offline-First Data & Sync.

Whatever the level, the user interface has to communicate state: what is available offline, what is stale, and what is still pending upload. That is covered in Offline UX & Fallbacks.

How this section is organized

The pages build on each other in roughly this order. Read Cache Storage API first, then Caching Strategies and Precaching & Runtime Caching. Read the storage pages when your app needs structured data, files or guarantees about persistence.

  • Cache Storage API


    Every method of CacheStorage and Cache, the matching algorithm, addAll() atomicity, opaque responses, expiration, LRU trimming and size measurement.

    Cache Storage API

  • Caching Strategies


    Cache-first, network-first, stale-while-revalidate, cache-only and network-only, with timeouts, fallbacks and per-route selection.

    Caching Strategies

  • Precaching & Runtime Caching


    Build-time manifests, revisioning, install-time downloads, cache cleanup on activate, and runtime caches with limits.

    Precaching & Runtime Caching

  • Offline UX & Fallbacks


    Offline pages, placeholder images, connectivity detection, stale-content indicators and queued-action feedback.

    Offline UX & Fallbacks

  • HTTP Caching & Service Workers


    How Cache-Control, validators and Vary interact with a service worker, fetch() cache modes, and header recipes for PWAs.

    HTTP Caching & Service Workers

  • Storage Quotas & Persistence


    Per-browser quotas, estimate(), persist(), eviction policies, private browsing and handling QuotaExceededError.

    Storage Quotas & Persistence

  • IndexedDB


    Transactions, indexes, cursors, versioned schema migrations, durability hints and using IndexedDB from service workers.

    IndexedDB

  • Origin Private File System


    getDirectory(), sync access handles in workers, streaming writes and running SQLite on OPFS.

    Origin Private File System

Browser support

Support data as of September 2026. For live data, check MDN's Cache interface compatibility table and caniuse.

Feature Chrome / Edge Firefox Safari
Cache Storage in windows and workers ✅ 43 ✅ 41 (workers 44) ✅ 11.1 (iOS 11.3)
IndexedDB (including in service workers) ✅ ✅ ✅
OPFS getDirectory() ✅ 86 ✅ 111 ✅ 15.2
OPFS createSyncAccessHandle() (dedicated workers) ✅ 102 ✅ 111 ✅ 15.2
navigator.storage.estimate() ✅ 61 ✅ 57 ✅ 17
estimate().usageDetails ✅ 61 ❌ ❌
navigator.storage.persist() ✅ 55 ✅ 57 ✅ 15.2
Storage Buckets API ✅ 122 ⚠️ ❌ ❌
Static routing with a cache source ✅ 123 ❌ ✅ 27
Back/forward cache ✅ ✅ ✅

⚠️ Chrome's Storage Buckets article describes the implementation as IndexedDB-only, while MDN's compatibility data lists StorageBucket.caches and getDirectory() from Chrome 122. Feature-detect bucket.caches before you rely on it for Cache Storage.

Safari versions are for macOS. Safari on iOS and iPadOS shipped Cache Storage with service workers in iOS 11.3. The other Safari rows apply to iOS at the same version number.

Common pitfalls

  • Treating Cache Storage like the HTTP cache. Entries never expire on their own. Without explicit cleanup and expiration, a runtime cache grows until the origin hits its quota and gets evicted wholesale.
  • Putting the offline source of truth in localStorage. The service worker cannot read it, and background sync cannot replay it.
  • Precaching through a stale HTTP cache. Use hashed URLs or request precache entries with cache: "reload".
  • Caching opaque cross-origin responses casually. Each one can cost megabytes of quota in Chromium, and you cannot tell a 200 from a 500. Request them with CORS when the server allows it.
  • Forgetting that storage is per origin, not per app. Two PWAs or two service worker scopes on the same origin share Cache Storage and IndexedDB. Prefix cache names and delete only your own.
  • Assuming data is still there. Eviction, Safari's seven-day cap for non-installed sites, the user clearing site data, and private windows all produce an empty store. Every read path needs a network or empty-state fallback.
  • Caching personalized responses without cleanup. Cache Storage matching ignores cookies and credentials. Delete user-specific caches on logout.

Debugging

In Chromium DevTools, the Application panel lists Cache Storage (with each entry's headers, a preview and "Time Cached"), IndexedDB, Local and Session Storage, and a Storage view that shows usage per type and a Clear site data button. Its Back/forward cache view tests bfcache eligibility and lists blocking reasons. Firefox's Storage Inspector exposes Cache Storage, IndexedDB and Web Storage. From any console you can dump Cache Storage directly:

console snippet
for (const name of await caches.keys()) {
  const cache = await caches.open(name);
  const requests = await cache.keys();
  console.groupCollapsed(`${name}: ${requests.length} entries`);
  requests.forEach((request) => console.log(request.method, request.url));
  console.groupEnd();
}
console.table(await navigator.storage.estimate());

For systematic workflows, including simulating offline, bypassing the service worker and inspecting quota, see Browser DevTools.

Further reading

On this site

External references