Offline-first PWAs are progressive web apps architected to function seamlessly without internet connectivity by using service workers to cache critical assets and data, ensuring users can access content even during network failures or in areas with poor connectivity. Service worker caching strategies—including cache-first, network-first, and stale-while-revalidate—determine how PWAs handle resource requests, balance freshness with performance, and provide resilient user experiences across varying network conditions.
Building offline-capable applications used to require native development with complex synchronization logic and local database management. Progressive web apps changed this by bringing service workers to the web—programmable network proxies that intercept requests and implement sophisticated caching strategies with JavaScript.
This comprehensive guide explores offline-first architecture, service worker fundamentals, and the caching strategies that power resilient PWAs. Whether you're building your first offline-capable application or optimizing an existing PWA, you'll learn how to implement, test, and debug service worker caching for production applications.
What is Offline-First Architecture?
Offline-first is a design philosophy that prioritizes resilience and performance by assuming network connectivity is unreliable or absent. Rather than treating offline capability as an edge case, offline-first architecture makes it the default behavior.
Traditional vs. Offline-First Approach
Traditional Online-First:
- Application requests data from network
- If network fails, show error message
- User experience breaks without connectivity
- No fallback or cached content
Offline-First:
- Application checks local cache first
- Serves cached content immediately
- Fetches updates in background when network available
- Synchronizes changes when connectivity returns
This paradigm shift transforms PWAs from fragile web pages into resilient applications that work reliably regardless of network conditions.
Benefits of Offline-First PWAs
1. Resilience During Network Failures
Users never see "You are offline" error screens. The application continues functioning with cached data, providing graceful degradation rather than complete failure.
2. Improved Performance
Serving assets from cache is dramatically faster than network requests:
- Cache response: 5-20ms
- Network response on 3G: 500-2000ms
- Network response on WiFi: 50-200ms
Offline-first PWAs feel instantaneous even on slow networks.
3. Reduced Server Load
Caching assets locally reduces bandwidth consumption and server requests. For applications with millions of users, this translates to significant infrastructure savings.
4. Emerging Market Accessibility
In regions with spotty connectivity or expensive data plans, offline-first PWAs provide equitable access to digital services that would otherwise be unavailable.
5. Better User Experience
Users expect applications to work. Offline-first PWAs meet this expectation by eliminating the distinction between online and offline experiences.
When Offline-First Makes Sense
Offline-first architecture is ideal for:
- Content-heavy applications - News, documentation, blogs where content can be pre-cached
- Productivity tools - Note-taking, task managers, calendars with eventual synchronization
- E-commerce catalogs - Product browsing works offline, checkout requires connectivity
- Social feeds - Display cached posts while fetching updates in background
- Field service apps - Technicians working in areas without reliable connectivity
Offline-first may be overkill for:
- Real-time collaboration requiring constant connectivity
- Video streaming or large media that can't be reasonably cached
- Applications where data staleness creates serious problems
Service Worker Fundamentals
Service workers are the foundation of offline-first PWAs. Understanding their lifecycle, scope, and behavior is essential before implementing caching strategies.
What is a Service Worker?
A service worker is a JavaScript file that runs in the background, separate from your web page. It acts as a programmable network proxy, intercepting requests and determining how to respond—from cache, network, or custom logic.
Key characteristics:
- Runs on a separate thread - Doesn't block the main UI thread
- Event-driven - Responds to lifecycle events and fetch requests
- HTTPS required - Security requirement (localhost exempt for development)
- Cannot access DOM - Operates independently of web pages
- Programmable cache - Controls what gets cached and when
Service Worker Lifecycle
Service workers progress through distinct lifecycle states:
1. Registration
Your application registers the service worker:
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service worker registered:', registration.scope);
})
.catch((error) => {
console.error('Registration failed:', error);
});
}
2. Installation
The service worker downloads and installs. This is where you cache initial assets:
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = ['/', '/styles/main.css', '/scripts/app.js', '/images/logo.png'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
});
3. Activation
After installation, the service worker activates. Clean up old caches here:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)));
}),
);
});
4. Fetch Interception
The service worker intercepts network requests:
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request).then((response) => response || fetch(event.request)));
});
Service Worker Scope
The service worker scope determines which requests it can intercept. Scope is defined by the service worker file location:
- Service worker at
/sw.jscontrols scope/ - Service worker at
/app/sw.jscontrols scope/app/ - Cannot control requests outside its scope
Best practice: Place service workers at the root (/sw.js) to control the entire origin.
Updating Service Workers
When you modify sw.js, browsers detect the change and install a new service worker version. The new worker waits until all pages using the old worker close before activating.
Skip waiting to activate immediately:
self.addEventListener('install', (event) => {
self.skipWaiting(); // Activate immediately
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim()); // Take control of pages immediately
});
Warning: Skip waiting can cause unexpected behavior if assets are incompatible between versions. Use carefully.
Core Caching Strategies
Service workers support multiple caching strategies, each optimizing for different content types and network conditions. Understanding when to use each strategy is critical for building effective offline-first PWAs.
1. Cache-First (Cache Falling Back to Network)
How it works:
- Check cache for matching request
- If found, return cached response immediately
- If not found, fetch from network
- Optionally cache the network response for future requests
Best for:
- Static assets that rarely change (CSS, JavaScript, images)
- Versioned resources (e.g.,
app.v2.js,logo.v3.png) - Font files
- Third-party libraries
Implementation:
self.addEventListener('fetch', event => {
const { request } = event;
// Apply cache-first only to static assets
if (request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'image') {
event.respondWith(
caches.match(request)
.then(cached => {
if (cached) {
return cached;
}
return fetch(request).then(response => {
// Cache successful responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
});
})
);
}
});
Trade-offs:
- ✅ Fastest performance - Instant response from cache
- ✅ Fully offline-capable - Works without network
- ❌ Stale content - Users might see outdated assets until cache updates
- ❌ Storage overhead - All assets cached locally
2. Network-First (Network Falling Back to Cache)
How it works:
- Attempt to fetch from network
- If successful, return network response (optionally cache it)
- If network fails, fall back to cache
- If both fail, return error or offline page
Best for:
- API responses requiring fresh data
- User-generated content
- Social feeds
- Real-time data that tolerates graceful degradation
Implementation:
self.addEventListener('fetch', event => {
const { request } = event;
// Apply network-first to API requests
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
// Cache successful API responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(request)
.then(cached => {
if (cached) {
return cached;
}
// Both failed, return offline response
return new Response('Offline', {
status: 503,
statusText: 'Service Unavailable'
});
});
})
);
}
});
Trade-offs:
- ✅ Fresh content - Always attempts to get latest data
- ✅ Graceful degradation - Falls back to cache when offline
- ❌ Slower when online - Always makes network request first
- ❌ Timeout delays - Network failures can take time to detect
Optimization: Add timeout to network requests:
const fetchWithTimeout = (request, timeout = 3000) => {
return Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)),
]);
};
self.addEventListener('fetch', (event) => {
event.respondWith(fetchWithTimeout(event.request).catch(() => caches.match(event.request)));
});
3. Stale-While-Revalidate
How it works:
- Return cached response immediately (if available)
- Simultaneously fetch fresh data from network
- Update cache with fresh response for next request
- User sees stale content now, fresh content on next visit
Best for:
- Content where immediate freshness isn't critical
- Social media feeds
- News articles
- Product catalogs
- Avatar images
Implementation:
self.addEventListener('fetch', event => {
const { request } = event;
event.respondWith(
caches.match(request)
.then(cached => {
const fetchPromise = fetch(request)
.then(response => {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
});
// Return cached immediately, fetch in background
return cached || fetchPromise;
})
);
});
Trade-offs:
- ✅ Fast initial response - Instant from cache
- ✅ Always updating - Cache stays fresh over time
- ✅ Works offline - Serves stale content when network unavailable
- ❌ Stale content first - Users see old data initially
- ❌ Double bandwidth - Always fetching even if cached
4. Cache-Then-Network (Dual Requests)
How it works:
- Make two parallel requests: cache and network
- Return cached response immediately to UI
- When network response arrives, update UI and cache
- User sees instant response, then updated content
Best for:
- Content that needs immediate display with updates
- Stock prices, sports scores
- Real-time data with fallbacks
Implementation:
This strategy requires coordination between page and service worker:
In your page:
// Request from both cache and network
const cached = await caches.match('/api/data');
const network = fetch('/api/data')
.then((response) => response.json())
.then((data) => {
updateUI(data); // Update UI with fresh data
});
if (cached) {
const data = await cached.json();
updateUI(data); // Display cached data immediately
}
In service worker:
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/data')) {
event.respondWith(
fetch(event.request)
.then(response => {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, response.clone());
return response;
})
);
}
});
Trade-offs:
- ✅ Best of both worlds - Fast + fresh
- ✅ Progressive enhancement - Shows data, then updates
- ❌ Complex implementation - Requires page coordination
- ❌ Double requests - Uses more bandwidth
- ❌ UI flicker - Content updates can be jarring
5. Network-Only
How it works:
- Always fetch from network
- Never use cache
- Fail if network unavailable
Best for:
- POST, PUT, DELETE requests
- Authentication requests
- Real-time data that can't be stale
- Requests where caching breaks functionality
Implementation:
self.addEventListener('fetch', (event) => {
// Never cache POST requests
if (event.request.method !== 'GET') {
event.respondWith(fetch(event.request));
return;
}
// Apply other strategies for GET requests
});
Trade-offs:
- ✅ Always fresh - No stale content
- ✅ Simpler logic - No cache management
- ❌ No offline support - Breaks without network
- ❌ Slower - Network latency on every request
6. Cache-Only
How it works:
- Only return cached responses
- Never fetch from network
- Useful for precached app shell
Best for:
- App shell architecture
- Precached static assets you know exist
- Offline pages
Implementation:
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/app-shell/')) {
event.respondWith(caches.match(event.request));
}
});
Trade-offs:
- ✅ Guaranteed instant - No network delay
- ✅ Predictable - Always returns cached version
- ❌ Must be precached - Fails if not in cache
- ❌ Never updates - Requires service worker update to refresh
Choosing the Right Strategy
Different content types require different caching strategies. Here's a decision framework:
| Content Type | Strategy | Rationale |
|---|---|---|
| HTML pages | Network-first → Stale-while-revalidate | Fresh content preferred, fallback to cache |
| CSS/JS bundles | Cache-first (versioned URLs) | Immutable when versioned, instant load |
| Images/fonts | Cache-first | Rarely change, large file sizes benefit from caching |
| API data | Network-first or Stale-while-revalidate | Depends on staleness tolerance |
| User avatars | Stale-while-revalidate | Can be slightly stale, updates in background |
| Real-time data | Network-only | Stale data is worse than no data |
| POST/PUT requests | Network-only | Mutations must reach server |
| App shell | Cache-first or Cache-only | Static shell should load instantly |
Advanced Implementation Patterns
Pattern 1: Route-Based Strategy Selection
Apply different strategies based on URL patterns:
const CACHE_STRATEGIES = {
cacheFirst: [
/\.(?:png|jpg|jpeg|svg|gif|webp)$/,
/\.(?:woff|woff2|ttf|otf)$/,
/\/static\//
],
networkFirst: [
/\/api\//,
/\.html$/
],
staleWhileRevalidate: [
/\/images\/avatars\//,
/\/feed\//
]
};
self.addEventListener('fetch', event => {
const { request } = event;
const url = request.url;
// Determine strategy
let strategy;
if (CACHE_STRATEGIES.cacheFirst.some(pattern => pattern.test(url))) {
strategy = cacheFirst;
} else if (CACHE_STRATEGIES.networkFirst.some(pattern => pattern.test(url))) {
strategy = networkFirst;
} else if (CACHE_STRATEGIES.staleWhileRevalidate.some(pattern => pattern.test(url))) {
strategy = staleWhileRevalidate;
} else {
strategy = networkOnly;
}
event.respondWith(strategy(request));
});
// Strategy implementations
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
return cached || new Response('Offline', { status: 503 });
}
}
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const fetchPromise = fetch(request)
.then(response => {
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
});
return cached || fetchPromise;
}
function networkOnly(request) {
return fetch(request);
}
Pattern 2: Cache Expiration
Implement time-based cache expiration:
const CACHE_NAME = 'app-cache-v1';
const MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
async function cacheWithExpiration(request, response) {
const cache = await caches.open(CACHE_NAME);
const headers = new Headers(response.headers);
headers.set('sw-cached-at', Date.now().toString());
const modifiedResponse = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
cache.put(request, modifiedResponse);
}
async function getCachedIfFresh(request) {
const cached = await caches.match(request);
if (!cached) return null;
const cachedAt = cached.headers.get('sw-cached-at');
if (!cachedAt) return cached;
const age = Date.now() - parseInt(cachedAt);
return age < MAX_AGE ? cached : null;
}
self.addEventListener('fetch', (event) => {
event.respondWith(
getCachedIfFresh(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
cacheWithExpiration(event.request, response.clone());
return response;
});
}),
);
});
Pattern 3: Cache Size Limits
Prevent cache from growing unbounded:
const MAX_CACHE_SIZE = 50; // Maximum items per cache
async function trimCache(cacheName, maxSize) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxSize) {
// Delete oldest entries (FIFO)
const keysToDelete = keys.slice(0, keys.length - maxSize);
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
}
}
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(async (cached) => {
const response = cached || (await fetch(event.request));
if (!cached && response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, response.clone());
// Trim cache after adding
trimCache(CACHE_NAME, MAX_CACHE_SIZE);
}
return response;
}),
);
});
Pattern 4: Offline Fallback Page
Provide a custom offline experience:
const OFFLINE_PAGE = '/offline.html';
// Cache offline page during installation
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_PAGE)));
});
self.addEventListener('fetch', (event) => {
// Only handle navigation requests
if (event.request.mode === 'navigate') {
event.respondWith(fetch(event.request).catch(() => caches.match(OFFLINE_PAGE)));
}
});
Pattern 5: Background Sync
Queue failed requests for retry when connectivity returns:
// In your page
if ('serviceWorker' in navigator && 'sync' in registration) {
navigator.serviceWorker.ready.then((registration) => {
return registration.sync.register('sync-posts');
});
}
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-posts') {
event.waitUntil(syncPendingPosts());
}
});
async function syncPendingPosts() {
const cache = await caches.open('pending-posts');
const requests = await cache.keys();
return Promise.all(
requests.map(async (request) => {
try {
const response = await fetch(request);
if (response.ok) {
await cache.delete(request);
}
} catch (error) {
// Will retry on next sync
console.error('Sync failed:', error);
}
}),
);
}
Cache Invalidation and Updates
Cache invalidation is notoriously difficult. Here are strategies for keeping caches fresh:
Version-Based Cache Invalidation
Update the cache name when deploying new code:
const CACHE_VERSION = 'v2';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith('app-cache-') && name !== CACHE_NAME)
.map((name) => caches.delete(name)),
);
}),
);
});
Selective Cache Invalidation
Delete specific cache entries based on patterns:
async function invalidateCachePattern(pattern) {
const cacheNames = await caches.keys();
return Promise.all(
cacheNames.map(async (cacheName) => {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
const matchingKeys = keys.filter((request) => pattern.test(request.url));
return Promise.all(matchingKeys.map((key) => cache.delete(key)));
}),
);
}
// Invalidate all API cache
self.addEventListener('message', (event) => {
if (event.data.action === 'invalidate-api') {
event.waitUntil(invalidateCachePattern(/\/api\//));
}
});
Cache Busting with Query Parameters
Add timestamps or versions to URLs:
// In your app
const apiUrl = `/api/data?v=${Date.now()}`;
fetch(apiUrl);
Warning: This defeats caching entirely. Use sparingly.
HTTP Cache-Control Headers
Respect server cache directives:
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
const cacheControl = response.headers.get('Cache-Control');
// Don't cache if server says no-cache
if (cacheControl && cacheControl.includes('no-cache')) {
return response;
}
// Cache otherwise
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, response.clone());
return response;
})
);
});
Testing and Debugging Offline Functionality
Chrome DevTools
1. Application Tab → Service Workers
- View registered service workers
- Force update/unregister
- Simulate offline mode
- Bypass service workers for development
2. Application Tab → Cache Storage
- Inspect cached responses
- Delete specific cache entries
- View cache sizes
- Verify cache contents
3. Network Tab
- Filter by "Service Worker"
- See which requests are served from cache
- Inspect response headers
Testing Strategies
1. Offline Simulation
Test with DevTools offline mode:
// Detect offline/online
window.addEventListener('online', () => {
console.log('Back online');
});
window.addEventListener('offline', () => {
console.log('Lost connection');
});
2. Network Throttling
Test on slow connections (Slow 3G, Fast 3G) to verify performance benefits of caching.
3. Automated Testing
Use Puppeteer to test service worker behavior:
const puppeteer = require('puppeteer');
test('app works offline', async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Visit page while online
await page.goto('https://example.com');
// Go offline
await page.setOfflineMode(true);
// Navigate - should work from cache
await page.goto('https://example.com/about');
const content = await page.content();
expect(content).toContain('About');
await browser.close();
});
Common Issues and Solutions
Issue: Service worker not updating
Solution: Check cache-control headers on sw.js. Max-age should be 0:
Cache-Control: max-age=0
Issue: Old service worker stuck
Solution: Force update in DevTools or call:
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((reg) => reg.unregister());
});
Issue: Cache growing too large
Solution: Implement cache size limits (see Pattern 3 above).
Issue: Stale content persisting
Solution: Implement cache expiration or version-based invalidation.
Production Best Practices
1. Use Workbox
Workbox is Google's library for production service workers:
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');
workbox.routing.registerRoute(
({ request }) => request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
}),
);
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.NetworkFirst({
cacheName: 'api',
networkTimeoutSeconds: 3,
}),
);
Benefits:
- Battle-tested caching strategies
- Built-in cache expiration
- Precaching support
- Background sync helpers
2. Monitor Cache Performance
Track cache hit rates and performance:
self.addEventListener('fetch', (event) => {
const start = Date.now();
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
const duration = Date.now() - start;
logMetric('cache-hit', duration);
return cached;
}
return fetch(event.request).then((response) => {
const duration = Date.now() - start;
logMetric('cache-miss', duration);
return response;
});
}),
);
});
function logMetric(type, duration) {
// Send to analytics
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'metric',
data: { type, duration },
});
});
});
}
3. Handle Different Content Types
Apply appropriate strategies per content type:
const strategyMap = new Map([
['document', networkFirst],
['style', cacheFirst],
['script', cacheFirst],
['image', cacheFirst],
['font', cacheFirst],
]);
self.addEventListener('fetch', (event) => {
const strategy = strategyMap.get(event.request.destination) || networkFirst;
event.respondWith(strategy(event.request));
});
4. Provide User Controls
Let users manage cache:
// In your app
async function clearAppCache() {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
// Unregister service worker
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.unregister();
}
window.location.reload();
}
// Add to settings page
<button onclick="clearAppCache()">Clear Cache & Reload</button>;
5. Set Storage Quotas
Monitor storage usage:
if ('storage' in navigator && 'estimate' in navigator.storage) {
navigator.storage.estimate().then(({ usage, quota }) => {
const percentUsed = ((usage / quota) * 100).toFixed(2);
console.log(`Using ${percentUsed}% of storage quota`);
console.log(`${usage} bytes of ${quota} bytes used`);
});
}
Request persistent storage for critical apps:
if ('storage' in navigator && 'persist' in navigator.storage) {
navigator.storage.persist().then((persistent) => {
console.log('Persistent storage:', persistent);
});
}
Integration with Other PWA Features
Offline-first architecture works in concert with other PWA capabilities to create resilient applications.
Push Notifications
Service workers power both caching and push notifications. The same service worker file can handle both:
// Handle push notifications
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/images/icon.png',
}),
);
});
// Handle fetch for caching
self.addEventListener('fetch', (event) => {
event.respondWith(handleFetch(event.request));
});
For a comprehensive guide to implementing push notifications in PWAs, see Using Push Notifications in PWAs.
Background Sync
Queue failed requests for automatic retry:
// Queue request when offline
self.addEventListener('fetch', (event) => {
if (event.request.method === 'POST') {
event.respondWith(
fetch(event.request.clone()).catch(async () => {
// Store for background sync
const cache = await caches.open('sync-queue');
await cache.put(event.request, new Response());
// Register sync
await self.registration.sync.register('sync-posts');
return new Response('Queued for sync', { status: 202 });
}),
);
}
});
IndexedDB for Complex Data
For structured data beyond simple request/response caching:
// Store structured data
const db = await openDB('app-db', 1, {
upgrade(db) {
db.createObjectStore('posts', { keyPath: 'id' });
},
});
await db.add('posts', {
id: 1,
title: 'Post Title',
content: 'Post content...',
});
// Retrieve later
const posts = await db.getAll('posts');
Platform-Specific Considerations
iOS Safari Limitations
Safari has stricter service worker limitations:
- 7-day cache expiration - iOS automatically purges service worker caches after 7 days of inactivity
- Storage quotas - More aggressive eviction policies than Chrome
- Background sync - Not supported
- Push notifications - Requires iOS 16.4+ and home screen installation
Mitigation strategies:
- Prioritize most critical assets within storage limits
- Re-cache on app launch
- Educate users about home screen installation for full functionality
For detailed iOS PWA considerations, see Best Practices for iOS PWA Push Notifications and Essential PWA Strategies for Enhanced iOS Performance.
Android Chrome
Chrome on Android provides the best PWA support:
- Automatic install prompts based on engagement
- Full service worker capabilities
- Generous storage quotas
- Background sync support
Desktop Browsers
Desktop Chrome, Edge, and Firefox offer excellent PWA support with generous storage and full service worker features.
Choosing Between PWAs and Native Apps
When evaluating whether offline-first PWAs meet your needs, consider the comparison framework in PWA vs Native App: When to Build an Installable Progressive Web App. This guide helps teams decide when PWA offline capabilities are sufficient versus when native development is required.
Conclusion
Offline-first architecture powered by service worker caching transforms progressive web apps from fragile web pages into resilient applications that work reliably across varying network conditions.
Key takeaways:
- Choose strategies per content type - Static assets benefit from cache-first, dynamic content from network-first or stale-while-revalidate
- Test offline thoroughly - Use DevTools, network throttling, and automated tests
- Implement cache invalidation - Version-based or selective invalidation prevents stale content
- Monitor performance - Track cache hit rates and storage usage
- Consider platform limitations - iOS Safari has stricter constraints than Chrome
By implementing appropriate caching strategies and following production best practices, you can build PWAs that deliver fast, reliable experiences regardless of network connectivity—bridging the gap between web and native apps.
For teams building comprehensive PWAs, combine offline-first caching with push notifications to create engaging, resilient applications that rival native app experiences.
Frequently Asked Questions
How much storage can service workers use?
Storage limits vary by browser and available disk space. Chrome typically allows 60% of available disk space, with a minimum of several hundred MB. Use the Storage API to check quotas:
navigator.storage.estimate().then(({ usage, quota }) => {
console.log(`${usage} of ${quota} bytes used`);
});
Can service workers cache POST requests?
Service workers can intercept POST requests but shouldn't cache them. POST requests typically involve mutations (creating/updating data) that shouldn't be replayed from cache. Use network-only strategy for POST/PUT/DELETE requests.
How do I debug service worker caching issues?
Use Chrome DevTools:
- Application tab → Service Workers (update/unregister)
- Application tab → Cache Storage (inspect cache contents)
- Network tab → Filter by "ServiceWorker" (see which requests are cached)
- Console → Log cache operations in service worker code
What happens when cache storage is full?
Browsers automatically evict cached data when storage is full, typically using LRU (Least Recently Used) eviction. Implement cache size limits and expiration to control cache growth before browser eviction occurs.
Should I cache third-party resources?
Yes, but carefully. Cache third-party assets (fonts, libraries) using cache-first for performance. However, ensure you respect cache-control headers and licensing. Some CDNs explicitly prohibit caching.
How do I update cached content?
Common strategies:
- Version-based cache names (update cache name in new service worker)
- Time-based expiration (delete old cache entries periodically)
- Stale-while-revalidate (update in background)
- Force update via messaging (user-initiated cache clear)
Can I use service workers without HTTPS?
Only on localhost for development. Production requires HTTPS because service workers have powerful capabilities that must be secured. Use Let's Encrypt for free SSL certificates.
How do offline-first PWAs compare to native apps?
Offline-first PWAs approach native app capabilities for content-heavy applications but have limitations for hardware access and background processing. For a detailed comparison, see PWA vs Native App: When to Build an Installable Progressive Web App.
