Service Workers and PWA Caching Strategies Deep Dive
I wanted my app to work offline, but it felt impossible. Then I understood Service Worker caching strategies, and suddenly it clicked.
The "Aha!" Moment: Service Workers Are Network Proxies
I was frustrated. My note-taking app showed a blank screen whenever users lost connection. Meanwhile, Twitter, Google Maps, and Notion worked perfectly offline. How?
The secret was Service Workers. Think of them as a proxy server sitting between your app and the network. When your page requests /api/posts, the Service Worker intercepts it and decides:
- "Got it cached? Here you go"
- "Not cached? Let me fetch it"
- "Cached but stale? Take the cache while I update it in the background"
This decision logic is the caching strategy. It's like having a smart assistant at your fridge who knows when to serve what's inside, when to go shopping, and when to do both.
The Lifecycle: Install, Activate, Fetch
Service Workers go through three phases:
// sw.js
const CACHE_VERSION = 'v1';
const CACHE_NAME = `my-app-${CACHE_VERSION}`;
// Install: Pre-cache essential files
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html'
]);
})
);
self.skipWaiting();
});
// Activate: Clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
return self.clients.claim();
});
// Fetch: Intercept every network request
self.addEventListener('fetch', (event) => {
// Caching strategy goes here
});
The install phase pre-caches critical files. Activate cleans up old versions. Fetch handles runtime caching. That's the entire flow.
Five Caching Strategies That Matter
1. Cache First: Check cache, fallback to network
- Use for: Images, fonts, CSS, JS bundles (static assets)
- Why: These files never change, so serve them instantly from cache
2. Network First: Try network, fallback to cache
- Use for: API data, news feeds, user profiles
- Why: Fresh data matters, but offline fallback is better than nothing
3. Stale While Revalidate: Serve cache immediately, update in background
- Use for: Profile pictures, settings, analytics
- Why: Speed + freshness. Users see content instantly, get updates next time
4. Network Only: Never cache
- Use for: Payment endpoints, authentication, sensitive data
- Why: Some things should never be cached
5. Cache Only: Never hit network
- Use for: Fully offline apps (rare)
- Why: Special cases like offline games
Here's how I combined them in practice:
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// HTML: Network First (fresh pages preferred)
if (request.destination === 'document') {
event.respondWith(networkFirst(request));
}
// Static files: Cache First (instant loading)
else if (['image', 'font', 'style', 'script'].includes(request.destination)) {
event.respondWith(cacheFirst(request));
}
// API: Stale While Revalidate (speed + freshness)
else if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(request));
}
// Default: Network Only
else {
event.respondWith(fetch(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}
The response.clone() part confused me at first. Response objects are streams that can only be read once. To cache it, you need a copy. Give the original to the user, store the clone.
Workbox: The Easy Button
Writing raw Service Workers gets repetitive. Google's Workbox library simplifies everything:
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Pre-cache build files
precacheAndRoute(self.__WB_MANIFEST);
// Images: Cache First + expiration
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// API: Stale While Revalidate + 5min cache
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 5 * 60,
}),
],
})
);
Half the code, all the power. Expiration, size limits, and response filtering come as plugins.
For Vite projects, use vite-plugin-pwa:
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'My App',
short_name: 'App',
theme_color: '#ffffff',
},
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxAgeSeconds: 3600 },
},
},
],
},
}),
],
};
Next.js has next-pwa with similar config. The ecosystem makes PWA setup almost trivial now.
PWA Features: Install Prompts and Push Notifications
Controlling the install prompt gives you better UX:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event;
showInstallButton(); // Show custom button
});
installButton.addEventListener('click', async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User choice: ${outcome}`);
});
Push notifications require subscription:
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: PUBLIC_VAPID_KEY,
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
});
}
// In Service Worker
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon.png',
})
);
});
Background Sync lets users submit forms offline:
// Save to IndexedDB, register sync
await registration.sync.register('sync-form');
// In Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-form') {
event.waitUntil(submitPendingForms());
}
});
This is how Google Keep works. Write offline, sync when online. Users don't even notice.
Debugging in Chrome DevTools
The Application tab is your best friend:
- Service Workers: View status, unregister, update
- Cache Storage: Inspect cached files, delete manually
- Network tab: Check "Offline" to test offline behavior
I use "Update on reload" during development to force Service Worker updates. In production, version your cache names properly:
const VERSION = 'v2.1.0';
const CACHE_NAME = `my-app-${VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) => {
return Promise.all(
names
.filter((n) => n.startsWith('my-app-') && n !== CACHE_NAME)
.map((n) => caches.delete(n))
);
})
);
});
This cleans up old caches automatically when you deploy new versions.
Gotchas I Learned the Hard Way
- HTTPS required: Service Workers only work on localhost or HTTPS
- Scope matters: A Service Worker at
/app/sw.jscan only control/app/* - Updates trigger on byte changes: Even a 1-byte change creates a new version
- Query strings create different cache keys:
/api?page=1and/api?page=2are separate - POST requests don't cache by default: And they shouldn't
The scope thing tripped me up. Put your Service Worker file at the root to control the entire app.
The Bottom Line
Service Workers blur the line between web and native apps. Offline support, background sync, push notifications—it's all possible now.
The key insights:
- Service Workers intercept network requests like a proxy
- Five strategies cover 99% of use cases
- Workbox eliminates boilerplate
- DevTools make debugging manageable
- Version your caches to avoid stale data
My app now works in airplane mode. Users write notes on the subway, and they sync when they emerge above ground. No one notices the network dropped. That's good UX.
The question shifted from "Can I make this work offline?" to "What caching strategy fits this data?" Images cache forever, API responses for 5 minutes, HTML serves fresh. Simple decisions, powerful results.