
Service Workers and PWA Caching Strategies Deep Dive
Making your app work offline requires understanding Service Worker caching strategies. Cache-first, network-first, and stale-while-revalidate explained.

Making your app work offline requires understanding Service Worker caching strategies. Cache-first, network-first, and stale-while-revalidate explained.
A comprehensive deep dive into client-side storage. From Cookies to IndexedDB and the Cache API. We explore security best practices for JWT storage (XSS vs CSRF), performance implications of synchronous APIs, and how to build offline-first applications using Service Workers.

Optimizing by gut feeling made my app slower. Learn to use Performance profiler to find real bottlenecks and fix what matters.

Text to Binary (HTTP/2), TCP to UDP (HTTP/3). From single-file queueing to parallel processing. Google's QUIC protocol story.

From HTML parsing to DOM, CSSOM, Render Tree, Layout, Paint, and Composite. Mastering the Critical Rendering Path (CRP), Preload Scanner, Reflow vs Repaint, and requestAnimationFrame.

I wanted my app to work offline, but it felt impossible. Then I understood Service Worker caching strategies, and suddenly it clicked.
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:
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.
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.
1. Cache First: Check cache, fallback to network
2. Network First: Try network, fallback to cache
3. Stale While Revalidate: Serve cache immediately, update in background
4. Network Only: Never cache
5. Cache Only: Never hit network
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.
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.
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.
The Application tab is your best friend:
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.
/app/sw.js can only control /app/*/api?page=1 and /api?page=2 are separateThe scope thing tripped me up. Put your Service Worker file at the root to control the entire app.
Service Workers blur the line between web and native apps. Offline support, background sync, push notifications—it's all possible now.
The key insights:
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.