Deliberately Storing HTTP Resources in the Browser
For a long time the browser cache was a black box. The Cache API gives developers full control over which requests get cached, how long they stay valid, and when they get replaced, independent of HTTP headers and browser heuristics.
Table of Contents
- 1. Why the Cache API exists
- 2. Basic principle: Cache Storage and caches.open()
- 3. Adding resources: add, addAll, and put
- 4. Cache hits with match and matchAll
- 5. Integrating with the Service Worker
- 6. Caching strategies compared
- 7. Detecting and refreshing staleness
- 8. Cache versioning and cleanup
- 9. Cache API vs. other storage mechanisms
- 10. Summary
- 11. FAQ
1. Why the Cache API exists
The browser's classic HTTP cache stores resources according to rules that HTTP servers dictate through response headers such as Cache-Control, ETag, and Expires. This mechanism works well for simple websites, but it quickly hits its limits with Progressive Web Apps and offline-first applications: developers have no way to deliberately read individual requests from the cache, swap out expired entries, or make cache-relevant decisions at runtime. This is exactly where the Cache API comes in. Available in all modern browsers since 2015, it offers full programmatic control over caching.
The Cache API is part of the Service Worker ecosystem, but it can also be used directly on the main thread via the global caches object. It stores request-response pairs, so not just URLs as strings, but complete Fetch objects with headers, body, and status code. That makes it more powerful than localStorage or sessionStorage, which only know strings, and enables scenarios such as offline fallbacks, background sync, and fast responses from local storage while fresh data loads in the background.
2. Basic principle: Cache Storage and caches.open()
The Cache API concept is built on a simple hierarchy: the browser provides a Cache Storage, a kind of namespace for multiple named caches. With caches.open('my-cache-v1') you open a specific cache or create it if it does not yet exist. The return value is a promise that resolves to a Cache object. Through this object you can add, read, search, and delete entries. Naming caches is a matter of convention: a version suffix like v1 or a hash makes it easy to introduce new cache versions on deploy and to deliberately remove old ones.
One important detail: the Cache API is fully asynchronous and promise-based. Every method, open, add, put, match, delete, returns a promise. That makes it compatible with async/await and prevents cache operations from blocking the main thread. Unlike localStorage and IndexedDB, which are used for synchronous or structured storage respectively, the Cache API is optimized specifically for HTTP resources and natively understands the request-response model.
// Open or create a named cache (versioning via suffix)
const CACHE_NAME = 'mironsoft-static-v3';
async function openCache() {
// caches is available in both main thread and Service Worker
const cache = await caches.open(CACHE_NAME);
return cache;
}
// List all existing cache names
async function listCaches() {
const cacheNames = await caches.keys();
console.log('Active caches:', cacheNames);
// ['mironsoft-static-v3', 'mironsoft-api-v1']
}
// Check if Cache API is available (not in private mode in some browsers)
if ('caches' in self) {
listCaches();
} else {
console.warn('Cache API not available');
}
3. Adding resources: add, addAll, and put
The Cache API offers three methods for storing resources, each at a different level of abstraction. cache.add(url) internally performs a fetch and stores the result. It fails if the response has a non-2xx status code. cache.addAll(urls) does the same for an array of URLs and fails atomically if any single request fails: either all resources get cached or none do. That is the right pattern for the initial app shell cache during Service Worker install.
The most flexible method is cache.put(request, response): it stores an arbitrary request-response pair without performing a fetch itself. This lets you modify responses before caching them, for example adjusting headers, reading and reassembling the body, or creating synthetic responses. A typical pattern with the Cache API: in the Service Worker's fetch handler, the network request is performed, the response is cloned (since streams can only be read once), the clone is cached, and the original is passed on to the browser.
// Service Worker install: precache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-shell-v3').then((cache) =>
// addAll fails atomically if any request fails
cache.addAll([
'/',
'/css/main.css',
'/js/app.js',
'/offline.html',
])
)
);
});
// Manual cache.put with response cloning
async function fetchAndCache(request) {
const cache = await caches.open('mironsoft-dynamic-v1');
const networkResponse = await fetch(request);
// Clone before caching (response body is a stream, readable only once)
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
}
// Synthetic response: cache a computed result
async function cacheComputedData(url, data) {
const cache = await caches.open('mironsoft-api-v1');
const response = new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
await cache.put(url, response);
}
4. Cache hits with match and matchAll
cache.match(request) searches a specific cache for a matching entry and returns the stored response or undefined. caches.match(request), on the global caches object, searches all caches in the order they were created. For most Service Worker fetch handlers, caches.match is the right choice, because you don't need to know which named cache holds the resource. With the option { ignoreSearch: true }, the query string is ignored during matching, which can be useful for parameterized API requests.
A subtle detail of the Cache API: by default the match algorithm compares URL, HTTP method, and the Vary header. Responses with a Vary: Accept-Language header are therefore cached separately per language. This can lead to unexpected behavior when the CDN and the Service Worker are configured differently. With { ignoreVary: true } this behavior can be disabled. cache.matchAll() without an argument returns all cached entries, useful for cache diagnostics and maintenance.
5. Integrating with the Service Worker
The Service Worker is the natural home for the Cache API. It sits as a proxy between the browser and the network and intercepts every fetch request. In the fetch event handler you decide, for each request, whether the response should come from the cache, the network, or a combination of both. The Service Worker lifecycle, install, activate, fetch, maps directly onto the cache phases: precache on install, clean up old caches on activate, apply a caching strategy on fetch.
A common mistake when integrating the Cache API into a Service Worker: forgetting event.waitUntil() in the install and activate handlers. Without waitUntil, asynchronous operations such as caches.open() and cache.addAll() may be aborted before they finish, because the browser marks the Service Worker as ready. event.waitUntil(promise) tells the browser to wait until the given promise resolves or rejects.
// Complete Service Worker with Cache API integration
const STATIC_CACHE = 'static-v3';
const API_CACHE = 'api-v1';
const STATIC_ASSETS = ['/', '/css/main.css', '/js/app.js', '/offline.html'];
// Install: precache static shell
self.addEventListener('install', (event) => {
self.skipWaiting(); // Activate immediately
event.waitUntil(
caches.open(STATIC_CACHE).then((c) => c.addAll(STATIC_ASSETS))
);
});
// Activate: remove old caches
self.addEventListener('activate', (event) => {
const allowed = [STATIC_CACHE, API_CACHE];
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => !allowed.includes(k)).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
// Fetch: route-based caching strategy
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API calls: network-first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE));
return;
}
// Static assets: cache-first
event.respondWith(cacheFirst(request, STATIC_CACHE));
});
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
return caches.match(request) ?? caches.match('/offline.html');
}
}
6. Caching strategies compared
The Cache API is only the foundation, the real decision lies in the caching strategy. Cache First responds immediately from the cache and only goes to the network on a miss. This is ideal for static assets like CSS and JS that rarely change. Network First always tries the network first and only falls back to the cache on network errors. This fits API endpoints where fresh data takes priority. Stale-While-Revalidate returns the cached version immediately and updates the cache in the background: the user sees no latency and gets the fresh version on the next reload.
The choice of strategy has direct consequences for user experience and data consistency. A too-aggressive Cache API strategy can lead users to see outdated data. A too-conservative strategy gives away the benefits of local caching. The best solution is a combination: static assets always Cache First, API calls either Network First or Stale-While-Revalidate depending on criticality, and navigation requests (HTML pages) Stale-While-Revalidate with a short validity period.
| Strategy | Order | Ideal for | Risk |
|---|---|---|---|
| Cache First | Cache → Network | CSS, JS, fonts, icons | Outdated content without versioning |
| Network First | Network → Cache | API data, auth routes | No response when offline without a cache |
| Stale-While-Revalidate | Cache + network in parallel | News, product listings, HTML | Briefly stale data on first load |
| Cache Only | Cache only | App shell, offline pages | No update without manual precaching |
| Network Only | Network only | Checkout, payment flows | No offline support |
7. Detecting and refreshing staleness
The Cache API itself has no concept of an expiration date. A response stays in the cache until it is explicitly deleted or the browser applies storage pressure. For time-based staleness there are two approaches: either you store a timestamp as a custom header on the cached response and check it on match, or you use the standard HTTP headers Date and Cache-Control: max-age of the cached response and compare them against Date.now(). The latter is more elegant, because it reuses the existing HTTP caching model.
A battle-tested pattern for the Cache API: on match, read the cached response, evaluate the Date header, and check whether the entry is older than a defined threshold. If so, hit the network, update the cache, and return the fresh response. If not, return the cache entry directly. This pattern combines the speed of Cache First with the freshness of Network First, and builds staleness intelligence directly into the fetch handlers without external libraries.
// Staleness check using cached Response Date header
const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
async function staleAwareMatch(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
const dateHeader = cached.headers.get('date');
const age = dateHeader ? Date.now() - new Date(dateHeader).getTime() : Infinity;
if (age < MAX_AGE_MS) {
return cached; // Fresh enough, serve from cache
}
}
// Stale or missing, fetch from network and update cache
try {
const fresh = await fetch(request);
if (fresh.ok) await cache.put(request, fresh.clone());
return fresh;
} catch {
// Network failed, return stale cache if available
return cached ?? Response.error();
}
}
// Stale-While-Revalidate pattern
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Background revalidation (does not block response)
const fetchPromise = fetch(request).then((fresh) => {
if (fresh.ok) cache.put(request, fresh.clone());
return fresh;
});
return cached ?? fetchPromise; // Return cache immediately if available
}
8. Cache versioning and cleanup
Without active maintenance, the Cache API's cache grows without bound. Browsers impose storage limits, typically 20 to 50 percent of available disk space, and may delete caches without warning when space runs low. That is why a clear versioning strategy is essential: the cache name contains a build hash or a version number. On Service Worker activate, all caches with outdated names get deleted. New requests land in the new cache, old entries are never used again and can be removed.
For fine-grained cache maintenance, the Cache API offers cache.delete(request) and caches.delete(cacheName). With cache.matchAll() you can list all cached entries and selectively delete them, for instance everything older than a week. Modern Workbox configurations provide an abstraction layer with automatic LRU eviction and expiry handling, but internally they build on the very same Cache API. Anyone who doesn't want to use Workbox can build their own cleanup system with a handful of helper functions.
9. Cache API vs. other storage mechanisms
The Cache API solves a specific problem: caching HTTP resources for offline scenarios and performance optimization. It is not the right storage for structured application data, that's what IndexedDB is for. It is not suited for small configuration values either, localStorage is simpler for that. The decisive advantage of the Cache API is its deep integration with the Fetch API and the request-response model: it understands HTTP semantics, can store responses with complete headers, and can be used inside Service Workers, which localStorage and sessionStorage cannot do.
A practical comparison: if you want to cache API responses for offline access, the Cache API is the right choice. If you want to search, filter, or run complex queries on the stored data, you should mirror the Cache API's responses into IndexedDB. If you just want to persist a token or a setting, localStorage is simpler. The three technologies don't compete, they complement each other: Service Worker with the Cache API for network resources, IndexedDB for application data, localStorage for simple key-value pairs.
Mironsoft
Progressive Web Apps, Service Workers, and performance architecture
Offline-capable web apps with the Cache API?
We implement robust Service Worker architectures with deliberately chosen caching strategies, for fast load times, offline support, and reliable Progressive Web Apps.
Service Worker setup
Lifecycle management, precaching, and routing strategies for production apps
Caching strategy
Cache First, Network First, and Stale-While-Revalidate by resource type
Performance audit
Lighthouse analysis, measuring cache hit rate, and surfacing optimization potential
10. Summary
The Cache API is the tool that gives developers back the control the browser's HTTP cache doesn't offer. With caches.open(), cache.put(), and caches.match(), request-response pairs can be managed with precision, independent of HTTP headers and browser heuristics. Combined with Service Workers, this produces robust offline-first architectures that apply different caching strategies per resource type: Cache First for static assets, Network First for critical API calls, Stale-While-Revalidate for content where freshness and speed matter equally.
The key to using the Cache API successfully comes down to three things: first, a consistent versioning strategy with hash suffixes in the cache name, second, clean cleanup in the Service Worker's activate handler, and third, a deliberate staleness strategy that prevents users from permanently seeing outdated data. Anyone who thinks through these three aspects builds web apps that load faster than native apps at startup and that keep working even without a network connection.
Cache API, the essentials at a glance
Core operations
caches.open(name), cache.put(req, res), caches.match(req), asynchronous, promise-based, available in both Service Worker and main thread.
Cloning responses
Always call response.clone() before caching, the response body is a stream and can only be read once.
Strategies
Cache First for static assets, Network First for APIs, Stale-While-Revalidate for dynamic content. Combine them by resource type.
Cache maintenance
Version suffix in the cache name, remove old caches with caches.delete() in the activate handler. Prevents unbounded cache growth.
11. FAQ: JavaScript Cache API
1What is the Cache API in JavaScript?
caches object) on the main thread.2How do you open a cache?
await caches.open('name'), returns a Cache object or creates a new cache with that name.3add() vs. put(), what's the difference?
add() performs the fetch itself. put() stores a given request-response pair. For fetch handlers, always use put() with a cloned response.4Why clone the response?
response.clone() creates a second identical object for caching.5How long do entries stay in the cache?
6caches.match() vs. cache.match()?
caches.match() searches all caches. cache.match() searches only the specific one. In a fetch handler, usually use caches.match().7How to implement Stale-While-Revalidate?
8Delete old caches on update?
activate handler, check caches.keys(), remove names no longer needed with caches.delete().9Use the Cache API without a Service Worker?
caches object on the main thread. Fetch requests, however, can only be intercepted inside a Service Worker.