Client-side caching is essential for performance and offline functionality. This guide covers various caching strategies for modern web applications.
Browser Cache
HTTP Cache Headers
# Cache-Control
Cache-Control: max-age=3600, public
Cache-Control: no-cache, must-revalidate
Cache-Control: no-store
Cache-Control: private
# ETag
ETag: "abc123"
# Last-Modified
Last-Modified: Mon, 27 Feb 2026 12:00:00 GMT
HTTP Caching Strategies
// Fetch with cache control
async function fetchWithCache(url, options = {}) {
const response = await fetch(url, {
...options,
cache: 'force-cache' // Use cache, ignore network
});
return response;
}
// Stale-while-revalidate
async function fetchStaleWhileRevalidate(url) {
const cache = await caches.open('api-cache');
const cachedResponse = await cache.match(url);
const fetchPromise = fetch(url).then(response => {
if (response.ok) {
cache.put(url, response.clone());
}
return response;
});
return cachedResponse || fetchPromise;
}
Service Worker Caching
Cache-First Strategy
// sw.js - Cache first, fall back to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request).then(networkResponse => {
// Cache successful responses
if (networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open('cache-v1').then(cache => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
);
});
Network-First Strategy
// sw.js - Network first, fall back to cache
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const responseClone = response.clone();
caches.open('api-cache').then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
}
});
Stale-While-Revalidate
// sw.js - Return cached, update in background
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('dynamic-cache').then(async cache => {
const cachedResponse = await cache.match(event.request);
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return cachedResponse || fetchPromise;
})
);
});
Cache-Only Strategy
// sw.js - Only serve from cache
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => response || new Response('Not in cache', {
status: 404
}))
);
});
Network-Only Strategy
// sw.js - Bypass cache entirely
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
IndexedDB
Basic Operations
// Open database
const request = indexedDB.open('MyApp', 1);
request.onerror = () => console.error('DB error:', request.error);
request.onsuccess = () => console.log('DB opened:', request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store
if (!db.objectStoreNames.contains('users')) {
const store = db.createObjectStore('users', { keyPath: 'id' });
store.createIndex('email', 'email', { unique: true });
store.createIndex('name', 'name', { unique: false });
}
};
// Add data
function addUser(user) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const request = store.add(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get data
function getUser(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['users'], 'readonly');
const store = transaction.objectStore('users');
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Get all
function getAllUsers() {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['users'], 'readonly');
const store = transaction.objectStore('users');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Update data
function updateUser(user) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const request = store.put(user);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Delete data
function deleteUser(id) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
IndexedDB Wrapper
class IndexedDB {
constructor(dbName, version, stores) {
this.dbName = dbName;
this.version = version;
this.stores = stores;
this.db = null;
}
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.name)) {
const objectStore = db.createObjectStore(
store.name,
store.options || {}
);
store.indexes?.forEach(index => {
objectStore.createIndex(
index.name,
index.keyPath,
index.options || {}
);
});
}
});
};
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onerror = () => reject(request.error);
});
}
async get(storeName, key) {
return this._transaction(storeName, 'readonly', store => store.get(key));
}
async getAll(storeName) {
return this._transaction(storeName, 'readonly', store => store.getAll());
}
async put(storeName, value) {
return this._transaction(storeName, 'readwrite', store => store.put(value));
}
async delete(storeName, key) {
return this._transaction(storeName, 'readwrite', store => store.delete(key));
}
_transaction(storeName, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = operation(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// Usage
const db = new IndexedDB('MyApp', 1, [
{
name: 'users',
options: { keyPath: 'id' },
indexes: [
{ name: 'email', keyPath: 'email', options: { unique: true } }
]
}
]);
await db.open();
await db.put('users', { id: 1, name: 'John', email: '[email protected]' });
const user = await db.get('users', 1);
React Query / SWR Caching
React Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache time (5 minutes)
staleTime: 5 * 60 * 1000,
// Cache time when window is not focused
gcTime: 10 * 60 * 1000,
// Refetch on window focus
refetchOnWindowFocus: true,
// Retry failed requests
retry: 3,
// Background refetch
refetchOnMount: true,
}
}
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
);
}
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
// Cache specific time
staleTime: 60 * 1000,
// Keep unused data
placeholderData: (previousData) => previousData,
});
if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return <div>{data.map(user => <User key={user.id} user={user} />)}</div>;
}
SWR
import useSWR from 'swr';
function useUser(id) {
const { data, error, isLoading, mutate } = useSWR(
`/api/users/${id}`,
fetcher,
{
// Revalidate on focus
revalidateOnFocus: true,
// Revalidate on reconnect
revalidateOnReconnect: true,
// Dedupe requests
dedupingInterval: 2000,
// Fallback data
fallbackData: initialUser,
// Refresh interval
refreshInterval: 0,
}
);
return {
user: data,
isLoading,
isError: error,
mutate
};
}
// Update and revalidate
async function updateUser(id, data) {
await mutate(
`/api/users/${id}`,
// Optimistic update
currentUser => ({ ...currentUser, ...data }),
false
);
try {
await fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
} catch {
// Rollback on error
mutate(`/api/users/${id}`);
}
}
Cache Invalidation
Time-Based
// Clear cache after time
setTimeout(() => {
caches.delete('old-cache');
}, 24 * 60 * 60 * 1000); // 24 hours
Version-Based
const CACHE_VERSION = 'v2';
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => !name.endsWith(CACHE_VERSION))
.map(name => caches.delete(name))
);
})
);
});
Manual
// Clear specific cache
await caches.delete('api-cache');
// Clear all caches
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
// Clear IndexedDB
const databases = await indexedDB.databases();
databases.forEach(db => {
indexedDB.deleteDatabase(db.name);
});
Offline Support
PWA Offline Page
// sw.js
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => {
// Return offline page for navigation
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
return new Response('Offline', { status: 503 });
})
);
});
Background Sync
// Register sync
if ('serviceWorker' in navigator && 'sync' in window.SyncManager) {
await navigator.serviceWorker.ready;
const registration = await navigator.serviceWorker.register('/sw.js');
await registration.sync.register('sync-messages');
}
// Handle sync in service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});
async function syncMessages() {
const messages = await getUnsentMessages();
for (const message of messages) {
try {
await sendToServer(message);
await markAsSent(message.id);
} catch (error) {
console.error('Sync failed:', error);
}
}
}
Summary
Caching strategies overview:
-
HTTP Cache
- Cache-Control headers
- ETag/Last-Modified
-
Service Worker
- Cache-first, Network-first
- Stale-while-revalidate
- Cache-only, Network-only
-
IndexedDB
- Client-side database
- Complex queries
- Large data storage
-
React Query / SWR
- Server state caching
- Auto refetch
- Optimistic updates
-
Cache Invalidation
- Time-based
- Version-based
- Manual
Choose the right strategy:
- Static assets: Cache-first
- API data: Network-first or stale-while-revalidate
- User-generated: IndexedDB
- Server state: React Query/SWR
Comments