Skip to main content
โšก Calmops

Client-Side Caching Strategies: Complete Guide

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:

  1. HTTP Cache

    • Cache-Control headers
    • ETag/Last-Modified
  2. Service Worker

    • Cache-first, Network-first
    • Stale-while-revalidate
    • Cache-only, Network-only
  3. IndexedDB

    • Client-side database
    • Complex queries
    • Large data storage
  4. React Query / SWR

    • Server state caching
    • Auto refetch
    • Optimistic updates
  5. 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