This is my 15th write up on dev.to site. A complete code of Service Worker that downloads only changed files on update — not the entire cache. With retry logic, multi-app isolation, update notifications, and an in-memory IndexedDB fallback.
Note: The code in this article (+ blogpost) was written by Claude Sonnet 4.6 (Anthropic) as part of an iterative review and refinement process. The architecture and requirements were designed by the author; the implementation and code review were AI-assisted.
Inspiration
This article builds directly on the idea presented by Ilja Nevolin in Smarter caching with service workers. His core insight was simple and elegant: instead of wiping the entire cache on every update, compare a checksum per file and only re-download what actually changed.
That concept is the foundation of everything here. What follows is a production-hardened expansion of it — with version strings instead of checksums, retry logic, timeout guards, multi-app isolation, an IndexedDB fallback, and an in-app update notification.
The problem with naive caching
Most Service Worker tutorials show you this pattern:
self.addEventListener('install', event => {
event.waitUntil(
caches.open('my-cache-v1').then(cache => cache.addAll(filesToCache))
);
});
It works. But when you deploy an update, the usual advice is to bump the cache name to my-cache-v2, which causes the new SW to wipe the old cache and re-download everything — even the 97 files you didn't touch.
For a small app this is fine. For a PWA with dozens of assets, icons, fonts, and a full JS bundle, this wastes bandwidth and makes updates noticeably slow, especially on mobile connections.
The smart approach: version per file
Define your assets as an array of objects, each with a file path and a version string:
const filesToCache = [
{ "file": "/app.js", "version": "008" },
{ "file": "/style.css", "version": "003" },
{ "file": "/logo.png", "version": "001" }
];
On every SW startup, compare these versions against what is stored in IndexedDB. Download only the files where the version has changed (or where the file is new). Everything else is served from the existing cache untouched.
This means a typical update — where you changed one JS file and one CSS file — downloads exactly two files instead of the whole app.
Key design decisions
1. event.waitUntil must wrap everything
This is a common mistake. If you call async functions outside event.waitUntil, the browser may terminate the SW before they complete:
// ❌ Wrong – browser can kill the SW before checkForUpdates() finishes
event.waitUntil(cleanOldCaches().then(() => {
checkForUpdates(); // NOT inside the promise chain
}));
// ✅ Correct – browser waits for the full chain
event.waitUntil(
cleanOldCaches().then(() => checkForUpdates())
);
2. No double-caching on install + activate
A natural instinct is to run performSmartCaching() in both the install and activate events. Don't. The install event already caches everything — calling it again in activate processes every file twice on each SW update. activate should only handle clients.claim() and cleaning old caches.
3. Bypass the browser's HTTP cache with cache: 'no-cache'
The browser has its own HTTP cache, separate from the Cache Storage API. Without this option, fetch() may silently return a stale response even when you've bumped the version:
const response = await fetch(fileInfo.file, { cache: 'no-cache' });
no-cache does not mean "never use cache" — it means "always revalidate with the server via ETag or Last-Modified." If the file hasn't changed, the server returns 304 and the browser uses its cached copy efficiently. If it has changed, you get the fresh version.
4. Fetch timeout via AbortSignal.timeout()
On a slow or unstable mobile connection, fetch() can hang for 60–120 seconds before the browser gives up. This stalls the entire install event. Guard every fetch with a timeout:
const response = await fetch(fileInfo.file, {
cache: 'no-cache',
signal: AbortSignal.timeout(10000) // 10 seconds max
});
Supported since Chrome 103 and Firefox 100.
5. Retry logic with exponential backoff — but not for permanent errors
Network hiccups and temporary server overload (5xx, 429) are worth retrying. A 404 or 403 is not — those won't be fixed by waiting a second:
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
const response = await fetch(fileInfo.file, { cache: 'no-cache', signal: AbortSignal.timeout(10000) });
if (!response.ok) {
if (RETRYABLE_STATUS_CODES.has(response.status)) {
throw new Error(`HTTP ${response.status} (retryable)`);
} else {
throw new Error(`HTTP ${response.status} (permanent)`); // breaks the loop
}
}
// success – break out of retry loop
return;
} catch (error) {
if (error.message.includes('permanent')) break;
const delay = 1000 * Math.pow(2, attempt - 1); // 1s → 2s → 4s
await sleep(delay);
}
}
6. Handle QuotaExceededError on cache.put()
On devices with little storage, or in private browsing mode, cache.put() can throw a QuotaExceededError. Without handling this, the entire install event would fail. Wrap the call separately so one failed file doesn't block the rest:
try {
await cache.put(fileInfo.file, response.clone());
} catch (quotaError) {
console.error(`Cache storage full, skipping ${fileInfo.file}`);
throw new Error('QuotaExceeded (permanent)');
}
7. IndexedDB fallback to an in-memory Map
IndexedDB can fail in rare situations: corrupted browser profile, strict privacy settings, storage quota exceeded at the DB level. Rather than crashing the SW, fall back gracefully:
let dbInstance = null;
let memoryStore = null; // activated on DB failure
async function getDB() {
if (dbInstance) return dbInstance;
if (memoryStore) return null; // already in fallback mode
try {
dbInstance = await openDB();
return dbInstance;
} catch (error) {
console.warn('[SW] IndexedDB unavailable, using in-memory fallback');
memoryStore = new Map();
return null;
}
}
All three DB functions (getStoredVersion, storeVersion, removeVersion) check if (!db) and use the Map instead. When in fallback mode, versions are lost on SW restart — all files re-download next time — but the app keeps working.
8. Multi-app isolation with APP_NAME
If you deploy multiple PWAs under the same origin (e.g. during development on localhost, or in a multi-tenant setup), they share the same Cache Storage and IndexedDB namespace. Two SWs using the same CACHE_NAME and DB_NAME will overwrite each other's data.
Fix: prefix everything with a per-app identifier:
const APP_NAME = 'my-app'; // change for each PWA
const CACHE_NAME = `${APP_NAME}-cache-v1`;
const DB_NAME = `${APP_NAME}-CacheDB`;
9. Clean path rules — no query strings in filesToCache
The removeObsoleteFiles() function and the fetch handler both match cached entries using url.pathname only (stripping query strings and fragments). This is intentional and correct — but it means paths in filesToCache must also be clean:
// ✅ correct
{ "file": "/app.js", "version": "008" }
// ❌ will break pathname matching
{ "file": "/app.js?v=123", "version": "008" }
Similarly, if your server responds to "/" with a 301 redirect to "/index.html", the redirect response itself gets cached instead of the HTML. If you observe broken offline behavior, replace "/" with "/index.html" in the list.
Periodic update check for long-running PWAs
The browser checks for SW updates on navigation and after 24 hours. But a PWA used as a standalone app may stay open for days without a page reload. Add a periodic check inside the SW itself:
const UPDATE_INTERVAL_MS = 60 * 60 * 1000; // every hour
function startPeriodicUpdateCheck() {
if (!UPDATE_INTERVAL_MS) return;
setInterval(async () => {
await checkForUpdates();
}, UPDATE_INTERVAL_MS);
}
// called at the end of the activate event
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([clients.claim(), cleanOldCaches()])
.then(() => startPeriodicUpdateCheck())
);
});
When new files are found, the SW notifies all open clients:
const allClients = await self.clients.matchAll();
allClients.forEach(client => {
client.postMessage({ type: 'CACHE_UPDATED', data: result });
});
User-facing update notification
The SW does its work silently on the background. The app needs to listen for the CACHE_UPDATED message and offer the user a gentle, non-intrusive way to apply the update.
Key UX principles:
- No technical jargon — say "A new version is ready", not "Service Worker cache invalidated"
- Never force a reload mid-session — the user might be filling a form
- A simple banner with two choices: Update now or Later
- If they choose Later, the new version loads naturally on next app start
Vanilla JS
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(registration => {
navigator.serviceWorker.addEventListener('message', event => {
if (event.data?.type === 'CACHE_UPDATED') showUpdateBanner();
});
});
}
function showUpdateBanner() {
if (document.getElementById('sw-update-banner')) return;
const banner = document.createElement('div');
banner.id = 'sw-update-banner';
banner.innerHTML = `
A new version is ready.
Update now
Later
`;
// … apply styles, append to body
document.getElementById('sw-update-now').addEventListener('click', () => {
banner.remove();
window.location.reload();
});
document.getElementById('sw-update-later').addEventListener('click', () => {
banner.remove();
});
}
Quasar + Vue 3 (Option API)
<template>
<q-banner v-if="updateAvailable" rounded class="update-banner bg-grey-9 text-white">
<template #avatar>
<q-icon name="system_update" color="white" />
</template>
A new version is ready.
<template #action>
<q-btn flat color="white" label="Update now" @click="reloadApp" />
<q-btn flat color="grey-4" label="Later" @click="dismiss" />
</template>
</q-banner>
</template>
<script>
export default {
name: 'UpdateBanner',
data() {
return { updateAvailable: false };
},
mounted() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then(() => {
this._swMessageHandler = (event) => {
if (event.data?.type === 'CACHE_UPDATED') this.updateAvailable = true;
};
navigator.serviceWorker.addEventListener('message', this._swMessageHandler);
});
},
beforeUnmount() {
if (this._swMessageHandler) {
navigator.serviceWorker.removeEventListener('message', this._swMessageHandler);
}
},
methods: {
reloadApp() { this.updateAvailable = false; window.location.reload(); },
dismiss() { this.updateAvailable = false; },
},
};
</script>
How it all fits together
First load
──────────
install → performSmartCaching()
├─ all files new → fetch all → store in Cache + IndexedDB
└─ skipWaiting()
activate → clients.claim()
→ cleanOldCaches()
→ startPeriodicUpdateCheck()
Subsequent loads (no change)
─────────────────────────────
install → performSmartCaching()
└─ all versions match → skip all → 0 downloads
fetch event → serve from cache (Cache First)
After deployment (2 files changed)
────────────────────────────────────
install → performSmartCaching()
├─ 2 files outdated → fetch 2 → update Cache + IndexedDB
└─ N-2 files current → skip
SW notifies app → banner shown → user clicks "Update now" → reload
Complete source
The full, annotated sw.js
// sw.js - Service Worker with intelligent caching and automatic updates
// Production version for PWA applications
// SW Version: 1.3.0
// ====================================================================
// APP IDENTITY
// Change APP_NAME for each PWA to avoid cache / IndexedDB collisions
// when multiple PWAs are installed on the same device.
// ====================================================================
const APP_NAME = 'my-app'; // <-- change for each PWA
const CACHE_NAME = `${APP_NAME}-cache-v1`;
const DB_NAME = `${APP_NAME}-CacheDB`;
const DB_VERSION = 1;
const FILES_STORE = 'cached-files';
// ====================================================================
// IMPORTANT: Update this array whenever your application files change.
// Increment the version string for every file you have modified.
//
// PATH RULES – follow these to avoid subtle caching bugs:
// ✅ "/app.js" – always use clean paths, no query strings
// ❌ "/app.js?v=123" – query strings break pathname matching in
// removeObsoleteFiles() and the fetch handler
//
// ROOT PATH NOTE:
// "/" is included for convenience, but some servers respond to it with
// a 301/302 redirect to "/index.html". If that happens, the redirect
// response is cached instead of the actual HTML, which breaks offline
// mode. If you observe this, remove "/" from the list and keep only
// "/index.html".
// ====================================================================
const filesToCache = [
{"file": "/", "version": "001"},
{"file": "/index.html", "version": "001"},
{"file": "/app.js", "version": "007"},
{"file": "/style.css", "version": "003"},
{"file": "/manifest.json", "version": "001"},
{"file": "/icons/icon-192.png", "version": "001"},
{"file": "/icons/icon-512.png", "version": "001"}
// Add all your application files here
];
// Retry configuration
const RETRY_ATTEMPTS = 3; // Number of download attempts per file
const RETRY_DELAY_MS = 1000; // Initial delay between retries (ms), doubles each attempt
const FETCH_TIMEOUT_MS = 10000; // Max time to wait for a single fetch (ms)
// Periodic update check interval – applies when the app stays open for
// a long time (e.g. PWA / kiosk). Set to 0 to disable.
const UPDATE_INTERVAL_MS = 60 * 60 * 1000; // Every 1 hour
// HTTP status codes worth retrying (temporary server-side issues)
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
// ====================================================================
// IndexedDB – shared instance + in-memory fallback
//
// IndexedDB can fail in rare situations (storage quota exceeded,
// browser privacy settings, corrupted profile). In that case we fall
// back to a plain Map so the SW keeps working for the current session.
// Versions stored in the Map are lost on SW restart, meaning all files
// will be re-downloaded next time – a minor performance cost, not a
// data-loss issue.
// ====================================================================
let dbInstance = null; // Persistent IndexedDB connection
let memoryStore = null; // In-memory fallback (Map), null when DB is healthy
async function getDB() {
if (dbInstance) return dbInstance;
if (memoryStore) return null; // Already in fallback mode
try {
dbInstance = await openDB();
return dbInstance;
} catch (error) {
console.warn('[SW] ⚠️ IndexedDB unavailable, switching to in-memory fallback:', error.message);
memoryStore = new Map();
return null;
}
}
// ====================================================================
// Install event
// ====================================================================
self.addEventListener('install', event => {
console.log('[SW] 📦 Installing Service Worker...');
// skipWaiting activates the new SW immediately without waiting
// for all tabs using the old SW to be closed.
self.skipWaiting();
// All async work must be wrapped in event.waitUntil so the browser
// keeps the SW alive until caching is complete.
event.waitUntil(
performSmartCaching()
.then(() => {
console.log('[SW] ✅ Installation complete');
})
.catch(error => {
console.error('[SW] ❌ Installation failed:', error);
})
);
});
// ====================================================================
// Activate event
// ====================================================================
self.addEventListener('activate', event => {
console.log('[SW] 🚀 Activating Service Worker...');
event.waitUntil(
Promise.all([
// Take control of all open tabs immediately
clients.claim(),
// Remove cache stores left by previous SW versions
cleanOldCaches()
])
.then(() => {
console.log('[SW] ✅ Activation complete');
// NOTE: performSmartCaching() is intentionally NOT called here.
// It already ran during the install event; calling it again
// would process every file twice on each SW update.
// Start periodic update check after activation
startPeriodicUpdateCheck();
})
);
});
// ====================================================================
// Periodic update check
// Runs inside the SW so it works even when the app JS has no interval.
// The interval is cleared automatically when the SW is terminated.
// ====================================================================
function startPeriodicUpdateCheck() {
if (!UPDATE_INTERVAL_MS) return;
console.log(`[SW] ⏰ Periodic update check enabled (every ${UPDATE_INTERVAL_MS / 60000} min)`);
setInterval(async () => {
console.log('[SW] ⏰ Periodic update check triggered');
await checkForUpdates();
}, UPDATE_INTERVAL_MS);
}
// ====================================================================
// Core smart caching logic
// ====================================================================
async function performSmartCaching() {
console.log('[SW] 🔄 Starting smart caching...');
const cache = await caches.open(CACHE_NAME);
const db = await getDB(); // May be null when using memory fallback
let newFiles = 0;
let updatedFiles = 0;
let skippedFiles = 0;
let failedFiles = 0;
for (const fileInfo of filesToCache) {
try {
const storedVersion = await getStoredVersion(db, fileInfo.file);
if (!storedVersion) {
// File has never been cached before
console.log(`[SW] 🆕 New file: ${fileInfo.file} (v${fileInfo.version})`);
await fetchAndCache(cache, db, fileInfo);
newFiles++;
} else if (storedVersion !== fileInfo.version) {
// File version has changed – download the new version
console.log(`[SW] 🔄 Update: ${fileInfo.file} (v${storedVersion} → v${fileInfo.version})`);
await fetchAndCache(cache, db, fileInfo);
updatedFiles++;
} else {
// File is up to date – skip
skippedFiles++;
}
} catch (error) {
// fetchAndCache already exhausted all retry attempts and
// logged the details – count the failure and move on.
failedFiles++;
}
}
// Remove files from cache that are no longer in filesToCache
await removeObsoleteFiles(cache, db);
console.log('[SW] 📊 Caching summary:');
console.log(`[SW] - New files: ${newFiles}`);
console.log(`[SW] - Updated files: ${updatedFiles}`);
console.log(`[SW] - Skipped (up to date): ${skippedFiles}`);
console.log(`[SW] - Failed: ${failedFiles}`);
return { newFiles, updatedFiles, skippedFiles, failedFiles };
}
// ====================================================================
// Fetch a file and store it in both Cache Storage and IndexedDB.
// Retries on network errors and retryable HTTP status codes.
//
// Retry logic:
// - Network failure / timeout → retry with backoff
// - HTTP 5xx / 429 (temporary server issues) → retry with backoff
// - HTTP 404 / 403 / 401 (permanent errors) → fail immediately
//
// Each fetch is guarded by FETCH_TIMEOUT_MS via AbortSignal.timeout().
// Without a timeout, a hanging mobile connection can stall the entire
// install event for 60–120 seconds before the browser gives up.
// ====================================================================
async function fetchAndCache(cache, db, fileInfo) {
let lastError;
for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
try {
// AbortSignal.timeout() cancels the fetch if it takes longer
// than FETCH_TIMEOUT_MS. Supported since Chrome 103 / Firefox 100.
const response = await fetch(fileInfo.file, {
cache: 'no-cache', // Always revalidate with server (ETag / Last-Modified)
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) {
if (RETRYABLE_STATUS_CODES.has(response.status)) {
throw new Error(`HTTP ${response.status} (retryable)`);
} else {
// Permanent error (e.g. 404, 403) – no point retrying
console.error(`[SW] ❌ Permanent error for ${fileInfo.file}: HTTP ${response.status}`);
throw new Error(`HTTP ${response.status} (permanent)`);
}
}
// --------------------------------------------------------
// Save to Cache Storage.
// Wrapped in try-catch because cache.put() throws a
// QuotaExceededError when storage is full (common on devices
// with little free space or in private browsing mode).
// We treat this as a failed file rather than crashing the SW.
// --------------------------------------------------------
try {
await cache.put(fileInfo.file, response.clone());
} catch (quotaError) {
console.error(`[SW] ❌ Cache storage full, could not store ${fileInfo.file}:`, quotaError.message);
throw new Error('QuotaExceeded (permanent)');
}
// Save version to IndexedDB (or in-memory fallback).
// If this write fails, the file is still cached correctly.
// The only consequence is that it will be re-downloaded on
// the next SW startup (minor performance impact, no data loss).
await storeVersion(db, fileInfo.file, fileInfo.version);
console.log(`[SW] 💾 Cached: ${fileInfo.file} (v${fileInfo.version})`);
return; // Success – exit retry loop
} catch (error) {
lastError = error;
const isPermanent = error.message.includes('permanent');
if (isPermanent) {
break; // Do not retry permanent errors
}
if (attempt < RETRY_ATTEMPTS) {
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // 1s → 2s → 4s
console.warn(`[SW] ⚠️ Attempt ${attempt}/${RETRY_ATTEMPTS} failed for ${fileInfo.file}. Retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
console.error(`[SW] ❌ All ${RETRY_ATTEMPTS} attempts failed for ${fileInfo.file}:`, lastError.message);
throw lastError;
}
// ====================================================================
// Simple sleep helper used for retry delays
// ====================================================================
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ====================================================================
// Check for updates – called by the periodic interval or from the app
// Notifies all open clients if any files were updated.
// ====================================================================
async function checkForUpdates() {
console.log('[SW] 🔍 Checking for updates...');
try {
const result = await performSmartCaching();
if (result.newFiles > 0 || result.updatedFiles > 0) {
console.log('[SW] 📢 Updates applied, notifying clients...');
const allClients = await self.clients.matchAll();
allClients.forEach(client => {
client.postMessage({
type: 'CACHE_UPDATED',
data: result
});
});
} else {
console.log('[SW] ✅ All files are up to date');
}
} catch (error) {
console.error('[SW] ❌ Error during update check:', error);
}
}
// ====================================================================
// Remove files from cache that are no longer listed in filesToCache
// ====================================================================
async function removeObsoleteFiles(cache, db) {
const currentFiles = new Set(filesToCache.map(f => f.file));
const cachedRequests = await cache.keys();
let removedCount = 0;
for (const request of cachedRequests) {
// Use pathname only (no query string / fragment) for matching.
// This is why filesToCache entries must use clean paths without
// query parameters – see the PATH RULES note at the top.
const pathname = new URL(request.url).pathname;
if (!currentFiles.has(pathname)) {
console.log(`[SW] 🗑️ Removing obsolete file: ${pathname}`);
await cache.delete(request);
await removeVersion(db, pathname);
removedCount++;
}
}
if (removedCount > 0) {
console.log(`[SW] 🗑️ Removed ${removedCount} obsolete file(s)`);
}
}
// ====================================================================
// Remove cache stores created by previous SW versions
// ====================================================================
async function cleanOldCaches() {
const cacheNames = await caches.keys();
const oldCaches = cacheNames.filter(name => name !== CACHE_NAME);
if (oldCaches.length > 0) {
console.log(`[SW] 🧹 Removing ${oldCaches.length} old cache store(s):`, oldCaches);
await Promise.all(oldCaches.map(name => caches.delete(name)));
}
}
// ====================================================================
// IndexedDB operations
// All functions accept db = null, which means the fallback Map is used.
// ====================================================================
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('[SW] ❌ Failed to open IndexedDB:', request.error);
reject(request.error);
};
request.onsuccess = () => {
console.log('[SW] ✅ IndexedDB opened');
resolve(request.result);
};
request.onupgradeneeded = event => {
console.log('[SW] 📀 Creating / upgrading IndexedDB schema...');
const db = event.target.result;
if (!db.objectStoreNames.contains(FILES_STORE)) {
// No version index needed – records are looked up by file path only.
db.createObjectStore(FILES_STORE, { keyPath: 'file' });
console.log('[SW] ✅ Object store created');
}
};
});
}
function getStoredVersion(db, fileName) {
if (!db) return Promise.resolve(memoryStore.get(fileName) ?? null);
return new Promise((resolve, reject) => {
const tx = db.transaction([FILES_STORE], 'readonly');
const store = tx.objectStore(FILES_STORE);
const request = store.get(fileName);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.version : null);
};
request.onerror = () => {
console.error(`[SW] ❌ Failed to read version for ${fileName}:`, request.error);
reject(request.error);
};
});
}
function storeVersion(db, fileName, version) {
if (!db) {
memoryStore.set(fileName, version);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const tx = db.transaction([FILES_STORE], 'readwrite');
const store = tx.objectStore(FILES_STORE);
const request = store.put({
file: fileName,
version: version,
lastUpdated: new Date().toISOString()
});
request.onsuccess = () => resolve();
request.onerror = () => {
console.error(`[SW] ❌ Failed to store version for ${fileName}:`, request.error);
reject(request.error);
};
});
}
function removeVersion(db, fileName) {
if (!db) {
memoryStore.delete(fileName);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const tx = db.transaction([FILES_STORE], 'readwrite');
const store = tx.objectStore(FILES_STORE);
const request = store.delete(fileName);
request.onsuccess = () => resolve();
request.onerror = () => {
console.error(`[SW] ❌ Failed to remove version for ${fileName}:`, request.error);
reject(request.error);
};
});
}
// ====================================================================
// Fetch event – Cache First strategy
// Serves files from cache; falls back to network on cache miss.
// ====================================================================
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
.catch(() => caches.match('/index.html'))
);
return;
}
event.respondWith(
caches.match(event.request).then(response => {
if (response) return response;
console.log(`[SW] 🌐 Cache miss, fetching: ${event.request.url}`);
return fetch(event.request).then(networkResponse => {
const pathname = new URL(event.request.url).pathname;
const fileInList = filesToCache.find(f => f.file === pathname);
if (fileInList && networkResponse.ok) {
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
});
}
return networkResponse;
}).catch(error => {
console.error(`[SW] ❌ Network unavailable for: ${event.request.url}`, error);
if (event.request.mode === 'navigate') {
return caches.match('/index.html');
}
throw error;
});
})
);
});
// ====================================================================
// Messages from the main application
// ====================================================================
self.addEventListener('message', event => {
if (!event.data) return;
if (event.data.type === 'SKIP_WAITING') {
console.log('[SW] ⏩ Skipping wait, activating now...');
self.skipWaiting();
}
if (event.data.type === 'CHECK_UPDATES') {
console.log('[SW] 📡 Manual update check requested by app');
checkForUpdates();
}
});
💡 To adapt for your app: change
APP_NAME, updatefilesToCachewith your actual files, and increment version strings whenever you deploy a change. That's it.
Summary of improvements over the original idea
| Feature | Original idea | This implementation |
|---|---|---|
| Selective file updates | ✅ checksum-based | ✅ version string-based |
event.waitUntil correctness |
⚠️ partial | ✅ full chain |
| Fetch timeout | ❌ | ✅ AbortSignal.timeout()
|
| Retry with backoff | ❌ | ✅ 3 attempts, exponential |
| Permanent vs. retryable errors | ❌ | ✅ distinguished |
QuotaExceededError handling |
❌ | ✅ per-file try-catch |
| IndexedDB fallback | ❌ | ✅ in-memory Map |
| Multi-app isolation | ❌ | ✅ APP_NAME prefix |
| Periodic update check | ❌ | ✅ configurable interval |
| User update notification | ❌ | ✅ non-intrusive banner |
Thanks to Ilja Nevolin (@codr) for the original concept that sparked this.
Top comments (0)