Introduction
Throughout my 12-year career as a Ruby on Rails Architect, the single biggest challenge I've encountered with web applications is ensuring seamless offline functionality. Many mobile users abandon websites that take longer than three seconds to load, emphasizing the need for robust offline capabilities in Progressive Web Apps (PWAs).
This tutorial will guide you through the essential steps to build a Progressive Web App that can function offline. You'll learn to implement service workers, which act as a proxy between your web application and the network, enabling caching of assets and handling of network requests.
By following the examples and best practices here you can create an engaging user experience that remains accessible regardless of connectivity. This includes practical patterns for caching, IndexedDB usage for structured offline storage, and Workbox integration to reduce boilerplate and maintenance burden.
Understanding Service Workers: The Backbone of Offline Functionality
What Are Service Workers?
Service workers are scripts that run in the background, separate from the web page. They enable features like push notifications and background sync, and — most importantly for offline-first PWAs — they intercept network requests to serve cached responses or fall back to the network.
In one travel booking project, we cached critical CSS and JS bundles (for example /assets/app.css and /assets/vendor.js) using a cache-first strategy; on constrained networks this reduced initial render latency substantially for first-time navigation to the main listing page.
The lifecycle of a service worker includes several states: installing, waiting, and active. Understanding these states is key to managing updates effectively. For example, use the activate event to remove old caches (see the activate example later) to avoid serving mixed-version assets.
- Run in the background
- Control network requests
- Enable offline capabilities
- Manage caching strategies
Example: a robust fetch handler that implements a stale-while-revalidate pattern and updates the cache safely.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
// Serve cached response immediately and revalidate in the background
fetch(event.request).then(networkResponse => {
if (networkResponse && networkResponse.ok) {
caches.open('v1').then(cache => cache.put(event.request, networkResponse.clone()));
}
}).catch(() => {});
return cachedResponse;
}
// No cache, try network and populate cache
return fetch(event.request).then(networkResponse => {
if (!networkResponse || !networkResponse.ok) return networkResponse;
return caches.open('v1').then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
}).catch(() => {
// Optional: return a generic fallback for navigation requests
return caches.match('/offline.html');
});
})
);
});
Notes on the example above:
- Always check
networkResponse.okbefore caching to avoid storing 4xx/5xx responses. - Clone responses before caching because Response objects are streams and are consumed once.
- Consider a dedicated cache for navigational fallbacks (e.g.,
/offline.html).
/offline.html) is used when both sources fail.Caching Strategies for Optimal Offline Performance
Effective Caching Techniques
Caching strategies are vital for ensuring a seamless offline experience. One common method is the Cache First strategy, where the app loads resources from the cache before checking the network. In an event ticketing app I worked on, caching static bundles and critical UI assets helped pages render instantly while network requests updated in the background.
Another effective strategy is the Network First approach, where the app attempts to fetch the latest data first. If the network is unavailable, it falls back to cached data. For example, in a weather app we used Network First for forecast JSON endpoints and Cache First for static UI assets.
Stale While Revalidate: This strategy serves cached content immediately while simultaneously checking the network for updates, then updates the cache for future requests. It's ideal for content that needs to be fresh but can tolerate showing slightly older data initially (e.g., news feeds, dashboards).
- Cache First for static assets
- Network First for dynamic content
- Stale While Revalidate for fresh data
- Cache with versioning for updates
Example: precaching essential files during install (version your cache name to force updates).
caches.open('v1').then(cache => {
return cache.addAll(['/index.html', '/styles.css', '/assets/app.css', '/assets/vendor.js']);
});
Notes:
- Version cache names (e.g.,
v1,v2) so you can delete old caches duringactivate. - Avoid caching large blobs unless necessary; prefer streaming or range requests for heavy media.
Implementing Offline Functionality: Step-by-Step Guide
Setting Up Your Service Worker
Service workers require secure contexts (HTTPS) except on localhost. Register the worker from your main script and handle install/activate lifecycle events to populate and prune caches.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('Service Worker registered:', reg.scope))
.catch(err => console.error('Service Worker registration failed:', err));
}
Implement the install event to cache resources. Use self.skipWaiting() judiciously and coordinate activation with clients when you need an immediate update.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then(cache =>
cache.addAll(['/index.html', '/styles.css', '/assets/app.css', '/assets/vendor.js'])
).then(() => self.skipWaiting())
);
});
In the activate event, remove old caches to avoid storage bloat and ensure users receive updated assets.
self.addEventListener('activate', (event) => {
const expectedCaches = ['v1'];
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.map(key => {
if (!expectedCaches.includes(key)) return caches.delete(key);
return Promise.resolve();
})
)).then(() => self.clients.claim())
);
});
Security and correctness tips:
- Only cache responses with
networkResponse.okto avoid storing error pages. - Validate CORS headers for cross-origin resources before caching.
- Limit cache size (evict LRU entries or use expiration libraries) to prevent storage exhaustion.
- Use HTTPS to enable service workers in production; test on
localhostfor development.
IndexedDB Example for Offline Storage
Why IndexedDB?
IndexedDB is the browser-native solution for structured storage and is well-suited for caching application state, queued user actions, or datasets that need to be queried offline. Use IndexedDB for larger structured payloads where the Cache API (keyed by request) is not sufficient.
Basic IndexedDB pattern (vanilla)
The following is a minimal, well-commented example to open a database, create an object store, and provide async helper functions to put/get records.
// Minimal IndexedDB helper (not production hardened)
function openDB(dbName = 'pwa-db', version = 1) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, version);
req.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('outbox')) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains('cache')) {
db.createObjectStore('cache', { keyPath: 'key' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function putItem(storeName, value) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const req = store.put(value);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function getItem(storeName, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// Usage example: queue a POST payload when offline
// await putItem('outbox', { url: '/api/submit', payload: { ... }, createdAt: Date.now() });
Practical tips:
- Consider using a lightweight library such as
idb(Jake Archibald) to avoid boilerplate and gain a promise-based API. - Design for possible eviction: store critical state server-side or allow rehydration after a sync.
- When using IndexedDB with service workers, be careful with contexts: service worker threads and window threads have separate connections—use messaging or sync events to coordinate.
Complete service-worker.js Example
Below is a compact, ready-to-use service-worker file that demonstrates install/activate/fetch, precaching an offline page, runtime caching for same-origin assets, and a simple cache size enforcement helper. Use this as a starting point and adapt cache names, retention policies, and routes for your app.
const CACHE_NAME = 'app-cache-v1';
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = ['/', '/index.html', '/styles.css', '/assets/app.css', OFFLINE_URL];
// Simple LRU-ish helper: remove oldest entries when cache grows beyond limit
async function enforceCacheLimit(cacheName, maxEntries = 50) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
if (requests.length <= maxEntries) return;
const deleteCount = requests.length - maxEntries;
for (let i = 0; i < deleteCount; i++) {
await cache.delete(requests[i]);
}
}
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
const expectedCaches = [CACHE_NAME];
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.map(key => (expectedCaches.includes(key) ? Promise.resolve() : caches.delete(key))))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const req = event.request;
// Only handle GET requests for caching; let non-GET pass through
if (req.method !== 'GET') return;
// Navigation requests: try network first, fallback to cache/offline
if (req.mode === 'navigate') {
event.respondWith(
fetch(req).then(networkRes => {
// Put a copy in cache for offline use
if (networkRes && networkRes.ok) {
const copy = networkRes.clone();
caches.open(CACHE_NAME).then(cache => cache.put(req, copy));
}
return networkRes;
}).catch(() => caches.match(OFFLINE_URL))
);
return;
}
// For other same-origin GET requests, stale-while-revalidate
event.respondWith(
caches.match(req).then(cached => {
const networkFetch = fetch(req).then(networkRes => {
if (networkRes && networkRes.ok) {
caches.open(CACHE_NAME).then(cache => cache.put(req, networkRes.clone()).then(() => enforceCacheLimit(CACHE_NAME, 100)));
}
return networkRes;
}).catch(() => undefined);
return cached || networkFetch.then(r => r).catch(() => cached);
})
);
});
Notes on adapting this file:
- Tune
enforceCacheLimitand cache names to match your storage budget and eviction policy. - Only cache same-origin or CORS-allowed resources; check CORS headers before caching cross-origin responses.
- Test install/activate flows in DevTools and ensure your service-worker scope is correct (file path matters).
Workbox Integration Example
Why use Workbox?
Workbox (v6+) provides tested building blocks for precaching, runtime caching strategies, expiration, and background sync helpers. It reduces boilerplate and helps avoid common pitfalls while offering plugins and strategies for common use cases.
Basic Workbox service worker snippet
Inside a Workbox-powered service worker file you typically use the autogenerated self.__WB_MANIFEST (injected by build tooling) for precaching and then register runtime routes.
// Example service-worker.js using Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
// Injected manifest during build by workbox plugins
precacheAndRoute(self.__WB_MANIFEST || []);
// Runtime caching for same-origin images
registerRoute(
({request}) => request.destination === 'image',
new StaleWhileRevalidate({
cacheName: 'images-runtime',
plugins: []
})
);
How self.__WB_MANIFEST gets generated
Use build tooling such as workbox-webpack-plugin (InjectManifest or GenerateSW) to produce and inject the manifest. Below is an example webpack plugin usage using InjectManifest so you can keep a custom service worker file while letting Workbox inject the file list.
// webpack.config.js (snippet)
const { InjectManifest } = require('workbox-webpack-plugin');
module.exports = {
// ... your config
plugins: [
new InjectManifest({
swSrc: './src/service-worker.js', // your custom service worker
swDest: 'service-worker.js',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024 // e.g., 5MB limit for assets
})
]
};
Practical notes:
- Use
InjectManifestwhen you need custom logic in your SW; useGenerateSWfor a fully managed SW generated by Workbox. - Check the Workbox repo for recipes and examples; Workbox helps implement expiration, cacheable-response checks, and background sync with fewer bugs than hand-rolled implementations.
- Always test the generated manifest and service worker lifecycle in your CI or local build pipeline before deploying.
Testing and Debugging Offline Features in PWAs
Using Developer Tools for Testing
Chrome DevTools provides a robust environment for simulating offline scenarios. Use the Application panel to inspect service workers, storage, and caches. The Network tab can simulate network conditions (Slow 3G, Offline) so you can measure perceived load times and validate your caching strategy.
Add console.log statements inside service worker lifecycle events while developing to trace install/activate/fetch flows. When assets fail to cache, common causes are incorrect paths, scope mismatches, or missing CORS headers for cross-origin requests.
- Use Chrome DevTools to simulate offline mode and throttling.
- Inspect caches and service worker status in the Application panel.
- Check for scope issues: service worker only controls pages at or below its file path.
- Unregister and re-register service workers when iterating on behavior.
Example fetch handler that falls back to network when no cache exists (safe caching):
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(response =>
response || fetch(event.request).then(networkResponse => {
if (!networkResponse || !networkResponse.ok) return networkResponse;
return caches.open('v1').then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
}).catch(() => caches.match('/offline.html'))
)
);
});
Troubleshooting checklist:
- Ensure the service worker file is served from the correct path (scope).
- Confirm the server uses HTTPS in production.
- Clear site data and unregister service workers if behavior is stale.
- Verify response headers (CORS, cache-control) for cached resources.
Browser Compatibility and Notes
Service workers and other PWA features are widely supported across modern browsers, but capabilities and edge-case behaviors differ. Always feature-detect APIs such as navigator.serviceWorker, the Cache API, Background Sync, Notifications, and IndexedDB before using them.
iOS Safari historically had limitations around background sync, push, and periodic background APIs, and the "Add to Home Screen" flow is less programmatic. Provide clear UI guidance for iOS users to add the PWA to the home screen and use local storage/IndexedDB fallbacks where background sync is unavailable.
Chrome (Android & Desktop) and other Chromium-based browsers broadly support Background Sync, Periodic Background Sync, Payment Handler, and other progressive APIs. Use feature detection (e.g., 'periodicSync' in registration) to gate behavior and avoid runtime errors on unsupported platforms.
- Use feature detection and graceful degradation for older or constrained browsers.
- Provide a lightweight online-only fallback that still communicates app limitations to users.
- Automate cross-browser tests with device farms (e.g., BrowserStack) or real-device labs for critical flows and validate on target devices.
- IndexedDB eviction and storage quotas vary across platforms—design for potential eviction and persist critical state to the server when possible.
Future Trends in Progressive Web Apps and Offline Support
Emerging Technologies
WebAssembly (Wasm) is being increasingly adopted to run compute-heavy tasks in the browser. Moving CPU-intensive logic to Wasm can reduce client-side processing time compared to JavaScript, enabling more complex offline-first logic on lower-end devices.
Workbox simplifies common caching strategies and service worker management. Use the Workbox project (see Workbox on GitHub) to implement precaching, runtime caching with expiration, and background sync helpers. Integrating Workbox can reduce boilerplate and hard-to-get-right edge cases in your service worker.
- WebAssembly integration for improved performance in compute-heavy tasks
- Automation tools like Workbox for robust, maintainable caching
- Enhanced offline support through IndexedDB for structured storage
- Push notifications using the Notifications API (where supported)
User Experience Enhancements
UX continues to be a focal point for PWAs. Gesture controls, dark mode, and responsive design make PWAs feel native. For dark mode, keep CSS concise and ensure sufficient contrast. When A/B testing UI changes, validate on real network conditions (throttled networks) to ensure offline and degraded experiences are acceptable.
.dark-mode { background-color: #121212; color: #ffffff; }
Key Takeaways
- Progressive Web Apps (PWAs) enable offline functionality through service workers, allowing users to access content without an internet connection.
- Implement caching strategies with the Cache API to ensure essential assets are available offline; version caches and prune old entries.
- Use IndexedDB for persistent structured storage and fallbacks when background sync or other advanced APIs are not available.
- Test PWA offline capabilities with DevTools and automated tools like Lighthouse; iterate on caching rules and fallbacks.
Frequently Asked Questions
- What is a service worker and how does it work?
- A service worker is a JavaScript file that runs in the background, separate from a web page. It intercepts network requests made by the web app, allowing it to cache resources and serve them even when the user is offline. To register a service worker, you typically use
navigator.serviceWorker.register('/service-worker.js')in your main script. This functionality is crucial for creating a reliable PWA. - How can I test my PWA's offline capabilities?
- Use browser DevTools (Application > Service Workers and the Network throttling panel) to simulate offline conditions. Run Lighthouse audits for guidance and use device farms such as BrowserStack to validate behavior on target devices.
- What are the best practices for caching in PWAs?
- Best practices include versioning caches, validating responses before caching, limiting cache size and entry lifetimes, and using strategies such as Cache First for static assets and Network First or Stale-While-Revalidate for dynamic content.
Conclusion
Progressive Web Apps leverage modern web technologies to deliver a seamless user experience, even in offline scenarios. Key concepts include service workers, which act as a proxy between the web app and the network, and caching strategies that ensure essential resources are available.
To further develop your PWA skills, start by building a simple application that uses service workers for caching and IndexedDB for structured offline data. Use Workbox to simplify production-grade caching and precaching, and iterate with DevTools and real-device testing.
Further Resources
- MDN Web Docs — Service Worker and web platform API references and compatibility notes.
- Workbox (GitHub) — Workbox repo with examples and recipes for precaching, runtime caching, and advanced strategies.
- Google Developers — General guides and tooling pointers for performance and PWAs.
- Google Chrome samples (GitHub) — Example projects demonstrating service workers and PWA patterns you can fork and run locally.
- BrowserStack — Device cloud for cross-browser and real-device testing (useful for validating PWA behavior across platforms).
