Frontend Engineering/
Lesson

Caching everything is not the right answer. A CSS file that hasn't changed in months is fine to serve from cache indefinitely. An APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. response that contains live stock prices should never come from cache. The strategies below are named patterns, each one is a different answer to the question: "should I hit the cache, the network, or both?"

Why different resources need different strategies

Your app is made of very different kinds of resources, and they have conflicting priorities:

Resource typePriorityBest strategy
HTML shell, CSS, JS bundlesSpeedCache First
API responses, user dataFreshnessNetwork First
Images, fontsSpeed with occasional updatesStale While Revalidate
Real-time data (prices, scores)AccuracyNetwork Only
Versioned assets (app.abc123.js)Speed, never staleCache Only
02

The five strategies

Cache only

Never contact the network. Return whatever is in cache, or fail. This only makes sense for assets that are pre-cached during install and never change, like content-hashed build artifacts.

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
    // returns undefined if not found - browser shows network error
  );
});

Network only

Always fetch from the network, never consult the cache. Use this for real-time data or analytics pings where a cached response would be wrong.

self.addEventListener('fetch', event => {
  event.respondWith(fetch(event.request));
});

Cache first

Check cache first; if not there, fetch from network and add to cache. This is the default strategy for most static assets.

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      if (cached) return cached;

      return fetch(event.request).then(networkResponse => {
        // Cache the new response for next time
        caches.open(CACHE_NAME).then(cache => {
          cache.put(event.request, networkResponse.clone());
        });
        return networkResponse;
      });
    })
  );
});
Cache First will happily serve a file that is months out of date. If you use this strategy for frequently-updated content, version your cache name (e.g. my-app-v2) and clear old caches in activate.

Network first

Try the network first; fall back to cache if the request fails. The user gets fresh data when online and a cached version when offline.

self.addEventListener('fetch', event => {
  event.respondWith(
    fetch(event.request)
      .then(networkResponse => {
        // Keep cache up to date while online
        caches.open(CACHE_NAME).then(cache => {
          cache.put(event.request, networkResponse.clone());
        });
        return networkResponse;
      })
      .catch(() => caches.match(event.request))
  );
});

Stale while revalidate

Return whatever is in cache immediately (so the UI loads fast), then fetch from the network in the background and update the cache for the next request. The user sees potentially stale content on first load, but fresh content on the next visit.

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      // Kick off a background refresh regardless
      const networkFetch = fetch(event.request).then(networkResponse => {
        caches.open(CACHE_NAME).then(cache => {
          cache.put(event.request, networkResponse.clone());
        });
        return networkResponse;
      });

      // Return cache immediately, or wait for network if no cache
      return cached || networkFetch;
    })
  );
});

This is the best general-purpose strategy for content pages, blog posts, and product listings, things that change occasionally but don't need to be real-time.

03

Building an offline fallback page

No matter which strategy you use, you should always have an offline.html to show when a navigation request fails and nothing is cached. Pre-cache it during install:

const STATIC_ASSETS = ['/', '/offline.html', '/styles.css', '/app.js'];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
  );
});

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() =>
        caches.match(event.request) || caches.match('/offline.html')
      )
    );
  }
});
04

Background syncWhat is background sync?A Service Worker API that queues network requests made while offline and replays them automatically when connectivity is restored.

Background sync lets you queue a failed write operation and replay it automatically when connectivity is restored, even if the user has closed your page.

// In your app: queue the action if offline
async function saveNote(data) {
  try {
    await fetch('/api/notes', { method: 'POST', body: JSON.stringify(data) });
  } catch {
    // Store locally and register a sync tag
    localStorage.setItem('pending-note', JSON.stringify(data));
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('sync-notes');
  }
}

// In sw.js: handle the sync event when connectivity returns
self.addEventListener('sync', event => {
  if (event.tag === 'sync-notes') {
    event.waitUntil(flushPendingNotes());
  }
});

async function flushPendingNotes() {
  const data = localStorage.getItem('pending-note');
  if (!data) return;
  await fetch('/api/notes', { method: 'POST', body: data });
  localStorage.removeItem('pending-note');
}
Background sync has good support on Chrome/Android but is still not available on iOS Safari. For maximum compatibility, implement an optimistic UI that retries on reconnect using the online event as a secondary mechanism.
05

Testing offline behavior

The fastest way to test is in Chrome DevTools:

  • Network tab → select "Offline" from the throttling dropdown
  • Application → Service Workers → check "Offline" to simulate for the SW scopeWhat is scope?The area of your code where a variable is accessible; variables declared inside a function or block are invisible outside it.
  • Application → Cache Storage → inspect what's actually cached

You can also listen for connectivity changes in your app code:

window.addEventListener('online', () => {
  console.log('Back online - syncing...');
  document.getElementById('status').textContent = 'Online';
});

window.addEventListener('offline', () => {
  console.log('Offline - using cached data');
  document.getElementById('status').textContent = 'Offline';
});

// Check current state on load
if (!navigator.onLine) {
  console.log('Starting in offline mode');
}
06

Quick reference

StrategyCache hitNetwork failBest for
Cache onlyReturn cacheReturn nothingVersioned static assets
Network onlyIgnore cacheShow errorReal-time data
Cache firstReturn cacheTry networkImages, fonts, CSS/JS
Network firstFall back to cacheReturn cacheAPI data, dynamic content
Stale while revalidateReturn cache + updateReturn cacheContent pages, listings
javascript
// Service Worker with differentiated strategies

const CACHE_NAME = 'my-app-v2';
const STATIC_CACHE = 'static-v2';
const DYNAMIC_CACHE = 'dynamic-v2';

const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192.png'
];

// Installation
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting();
});

// Activation
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames
          .filter(name => !name.startsWith('static') && !name.startsWith('dynamic'))
          .map(name => caches.delete(name))
      );
    })
  );
  self.clients.claim();
});

// Fetch with intelligent routing
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // HTML pages, Network First with offline fallback
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request)
        .catch(() => caches.match(request) || caches.match('/offline.html'))
    );
    return;
  }

  // API calls, Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(request)
        .then(response => {
          const clone = response.clone();
          caches.open(DYNAMIC_CACHE).then(cache => cache.put(request, clone));
          return response;
        })
        .catch(() => caches.match(request))
    );
    return;
  }

  // Static assets (JS, CSS), Cache First
  if (url.pathname.match(/\.(js|css)$/)) {
    event.respondWith(
      caches.match(request).then(cached => {
        if (cached) return cached;

        return fetch(request).then(response => {
          const clone = response.clone();
          caches.open(STATIC_CACHE).then(cache => cache.put(request, clone));
          return response;
        });
      })
    );
    return;
  }

  // Images, Stale While Revalidate
  if (url.pathname.match(/\.(png|jpg|jpeg|gif|webp|svg)$/)) {
    event.respondWith(
      caches.match(request).then(cached => {
        const fetchPromise = fetch(request).then(networkResponse => {
          caches.open(DYNAMIC_CACHE).then(cache => {
            cache.put(request, networkResponse.clone());
          });
          return networkResponse;
        }).catch(() => cached);

        return cached || fetchPromise;
      })
    );
    return;
  }

  // Default: Network First
  event.respondWith(
    fetch(request).catch(() => caches.match(request))
  );
});

// Background sync
self.addEventListener('sync', event => {
  if (event.tag === 'background-sync') {
    event.waitUntil(doBackgroundSync());
  }
});

async function doBackgroundSync() {
  // Sync pending data
  const db = await openDB('sync-store', 1);
  const pending = await db.getAll('pending');

  for (const item of pending) {
    try {
      await fetch(item.url, {
        method: item.method,
        body: item.body
      });
      await db.delete('pending', item.id);
    } catch (error) {
      console.error('Sync failed for', item.id);
    }
  }
}