Progressive Web Apps that work even without a network
A service worker is not an optional feature, it is the foundation for apps that give users a complete experience even with a poor or missing connection. Thinking Offline-First means building apps that are faster, more reliable and more installable than classic web applications.
Table of Contents
- 1. What a service worker really is
- 2. Lifecycle: install, activate, fetch
- 3. Caching strategies compared
- 4. Offline-First: fallback pages and shell caching
- 5. Background Sync: actions on reconnect
- 6. Push notifications: bringing users back
- 7. Workbox: service workers without boilerplate
- 8. Debugging and the update mechanism
- 9. Caching strategies in direct comparison
- 10. Summary
- 11. FAQ
1. What a service worker really is
A service worker is a JavaScript script that the browser runs in the background, independently of the page, with no access to the DOM, no blocking rendering, but with complete control over network requests. It sits as a programmable proxy between the web application and the network and decides, for every outgoing request, whether it is served from the cache, forwarded to the network, or replaced by a fallback. This position makes the service worker the central infrastructure component of any serious Progressive Web App.
The fundamental difference from a web worker lies in persistence: a service worker survives the closing of the browser tab and is reactivated by the browser as soon as a network request from its domain arrives or a push event comes in. This capability is the foundation for offline functionality, background message processing and push notifications. The service worker runs exclusively over HTTPS, on localhost HTTP is allowed for development purposes. Anyone who takes Offline-First seriously as a design principle plans the service worker in from the start instead of bolting it on afterwards.
2. Lifecycle: install, activate, fetch
The lifecycle of a service worker consists of three phases that must be understood exactly to avoid caching bugs and stale content. The first phase is install: as soon as the browser runs the registration script and detects a new service worker, the install event fires. Here you open a named cache and pre-load all static assets the application needs for offline operation. With event.waitUntil() you tell the browser not to mark the installation as complete until all assets have been cached.
The second phase is activate: after installation completes, the service worker waits until all tabs of the application are closed before it takes control. This prevents a running tab from suddenly being confronted with a new service worker that expects a different cache version. In the activate phase you clean up all old caches. The third phase is the ongoing fetch handler: every network request from the application triggers a fetch event, which the service worker can intercept and handle however it likes. This is where the caching strategies are implemented.
// service-worker.js (full lifecycle with cache versioning)
const CACHE_NAME = 'mironsoft-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/app.js',
'/styles.css',
'/offline.html',
'/icons/icon-192.png',
];
// Install: pre-cache all static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Pre-caching static assets');
return cache.addAll(STATIC_ASSETS);
})
);
// Take control immediately without waiting for tab reload
self.skipWaiting();
});
// Activate: delete old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => {
console.log('[SW] Deleting old cache:', key);
return caches.delete(key);
})
)
)
);
// Claim all open clients immediately
self.clients.claim();
});
3. Caching strategies compared
There is no universally best caching strategy for service workers, the right choice depends on the asset type and the requirements of the application. The Cache First strategy always serves from the cache and only falls back to the network if the asset is not present there. It is ideal for static assets like CSS, JavaScript and images that rarely change. The advantage is maximum speed; the downside is that stale content stays in the cache for a long time if cache versioning is not implemented cleanly.
The Network First strategy always queries the network first and falls back to the cache on failure. It suits API responses that should always be current but can still return usable stale data when offline. The Stale While Revalidate strategy serves immediately from the cache while simultaneously updating the cache in the background with the network response. It combines speed with freshness and is ideal for resources where slightly stale data is acceptable. A fourth strategy, Cache Only, serves exclusively from the cache, useful for assets that were pre-cached at install time and never change.
4. Offline-First: fallback pages and shell caching
The core pattern of Offline-First is the App Shell Model: the minimal HTML, CSS and JavaScript frame of the application is fully cached on the first visit and always loaded from the cache afterwards. Dynamic content is loaded separately via API calls and cached as well. This way the application starts instantly even without a network connection and at least shows cached content while it waits for a connection. The service worker is the infrastructure that implements this pattern.
For pages that are neither in the cache nor reachable over the network, you need an offline fallback page. It is pre-cached at install time and served by the fetch handler when a navigation request can be satisfied neither from the cache nor from the network. The fallback page informs the user about the offline state, shows cached content and offers a retry button. Well implemented offline fallback pages significantly improve the perceived reliability of an application, even if the user never actually sees them.
// service-worker.js (fetch handler with multiple strategies)
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Strategy: Cache First for static assets
if (request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'image') {
event.respondWith(
caches.match(request).then((cached) =>
cached ?? fetch(request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
return response;
})
)
);
return;
}
// Strategy: Network First for API calls
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
return response;
})
.catch(() => caches.match(request))
);
return;
}
// Strategy: Offline fallback for navigation requests
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
}
});
5. Background Sync: actions on reconnect
Background Sync solves a concrete problem of the Offline-First approach: users want to perform actions even without a connection, submitting forms, leaving reviews, saving data. Without Background Sync these actions are lost when the connection is missing. With the Background Sync API the application registers a sync tag whenever an action fails or is performed offline. The service worker receives the sync event as soon as the browser is back online, and then reliably retries the action, even if the tab has since been closed.
The implementation follows a clear pattern: the failed request is stored in IndexedDB. The sync tag is registered via navigator.serviceWorker.ready.then(reg => reg.sync.register('tag')). In the service worker, the sync event reads all stored requests from IndexedDB and resends them. If a request succeeded, it is removed from IndexedDB. If it fails again, the browser automatically schedules a new retry with exponential backoff. This mechanism makes offline actions just as reliable as online actions.
6. Push notifications: bringing users back
The Push API combined with the service worker enables notifications to users even when the web application is not open. The flow: the application requests notification permission, subscribes the browser to the push service (VAPID authentication) and sends the subscription endpoint to its own server. The server sends encrypted push messages to the browser's push service. The browser activates the service worker as soon as a push message arrives, independent of the state of the application.
VAPID (Voluntary Application Server Identification) is the standard for secure push authentication. The server generates a VAPID key pair, sends the public key to the application, and the subscription contains this key as an authentication feature. The browser's push service can then verify that only the authorized server can send messages for this subscription. The implementation on the Node.js side uses the web-push library; the service worker receives the push event and displays the notification with self.registration.showNotification().
// app.js (subscribe to push notifications)
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
// Public VAPID key from server
const publicKey = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription endpoint to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
}
// service-worker.js (handle incoming push event)
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'New message', body: '' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url ?? '/' },
})
);
});
// Handle notification click (open or focus the app)
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
7. Workbox: service workers without boilerplate
Workbox is a library developed by Google that wraps the most common service worker patterns into reusable modules. Instead of implementing caching strategies manually, you use registerRoute() with one of the built-in strategy objects: CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly or CacheOnly. Workbox handles cache management, expiration, cache size limits and error handling. The result is a noticeably shorter, more readable and more robust service worker.
For production projects, integration via workbox-webpack-plugin or vite-plugin-pwa is recommended. These plugins automatically generate the service worker from the build output, create the precache manifest list with content hashes, and update the service worker with every build. The Workbox service worker differs from a manually written one in that it versions precache entries by content hash: assets with identical content are not reloaded, changed assets are updated immediately. This is the production ready solution for cache invalidation without manual version bumps.
8. Debugging and the update mechanism
Service workers are notorious for making debugging difficult. Chrome DevTools offers a complete overview under Application → Service Workers: the active service worker, the waiting service worker, Cache Storage with all cached resources, and network logs filtered by service worker. The "Update on reload" checkbox forces a new service worker on every page reload and bypasses the waiting period, indispensable for development. "Bypass for network" temporarily disables the service worker and helps isolate network issues.
The update behavior of the service worker is one of the most common sources of confusion in production. When a new version of the service worker is registered, the old version stays active until all tabs are closed. This can mean users work with an old service worker for hours. The solution is self.skipWaiting() in the install handler combined with self.clients.claim() in the activate handler. This makes the new version take control immediately. Alternatively, the application can display a "new version available" banner and prompt the user to reload the page.
9. Caching strategies in direct comparison
Choosing the right service worker caching strategy is not an academic question, wrong decisions lead to stale content for users or unnecessary network traffic. The following table summarizes when which strategy is the right choice.
| Strategy | Flow | Ideal use case | Risk |
|---|---|---|---|
| Cache First | Cache → network (fallback) | Static assets (CSS, JS, fonts) | Stale assets without cache busting |
| Network First | Network → cache (fallback) | API data, dynamic pages | Slow on a poor connection |
| Stale While Revalidate | Cache immediately + network update in the background | News feeds, product listings | User briefly sees stale data |
| Cache Only | Cache only | Precached app shell | Failure without precaching |
| Network Only | Network only | Payment checkout, login | No offline support |
In practice, you combine several strategies within a single service worker: static assets via Cache First, API endpoints via Network First with cache fallback, the app shell via Cache Only, and security-critical endpoints via Network Only. Workbox makes this combination elegantly manageable through several independent registerRoute() calls, without the fetch handler degenerating into an unreadable switch statement.
Mironsoft
Progressive Web Apps, service workers and Offline-First architectures
Want to build a PWA with real offline support?
We plan and implement service workers, caching strategies and Background Sync for Progressive Web Apps, with measurable load-time improvements and robust offline operation.
PWA audit
Lighthouse analysis, service worker check and caching strategy review for existing applications
Workbox integration
Workbox setup with Webpack or Vite, precaching manifest and automatic cache invalidation
Background Sync
Offline actions with Background Sync and IndexedDB, forms and API calls even without a connection
10. Summary
The service worker is the technical foundation for Offline-First Progressive Web Apps. Its lifecycle of install, activate and fetch gives developers complete control over how and when assets are cached and network requests are answered. Choosing the right caching strategy, Cache First for static assets, Network First for dynamic data, Stale While Revalidate for feeds, is the decisive design decision that determines the speed and freshness of the application. Background Sync and the Push API extend the service worker with capabilities that classic web applications do not have.
Workbox significantly reduces boilerplate code and makes service workers maintainable for production projects. Integration into modern build tools like Vite and Webpack automates precache manifest generation and ensures that changed assets are invalidated immediately. Anyone who plans service workers into the architecture from the start rather than bolting them on afterwards builds applications that are faster, more reliable and more user friendly, and that the browser can offer as an installable app.
Service Worker & Offline-First: the essentials at a glance
Lifecycle
Install (precaching) → activate (cache cleanup) → fetch (strategy decision). skipWaiting() and clients.claim() for immediate activation of new versions.
Caching strategies
Cache First for static assets, Network First for APIs, Stale While Revalidate for feeds. Workbox wraps all strategies into reusable modules.
Offline-First pattern
Pre-cache the app shell, provide an offline fallback page, use Background Sync for failed actions, IndexedDB as an offline-capable data store.
Debugging
Chrome DevTools → Application → Service Workers. "Update on reload" for development. Lighthouse PWA audit for production checks.