Offline-first PWAs are progressive web apps built to work without an internet connection. They use service workers to cache key assets and data so users can access content during network failures or in areas with poor connectivity. Caching strategies like cache-first, network-first, and stale-while-revalidate control how a PWA handles requests. Each one balances freshness and speed differently.
Building offline apps used to mean native development with complex sync logic. Progressive web apps changed that. Service workers act as network proxies that sit between your app and the internet. They intercept requests and decide how to respond — from cache, from the network, or both.
This guide covers offline-first architecture, service worker basics, and the caching strategies that make PWAs work without a network. You will learn how to pick the right strategy, write the code, and debug issues in production.
What is Offline-First Architecture?
Offline-first is a design approach that treats the network as unreliable. Instead of showing error screens when the connection drops, your app keeps working with cached data. The network is a nice-to-have, not a requirement.
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 shift turns PWAs from fragile web pages into apps that work no matter what the network looks like.
Benefits of Offline-First PWAs
1. Resilience During Network Failures
Users never see "You are offline" error screens. The app keeps working with cached data instead of breaking completely.
2. Improved Performance
Serving assets from cache is much 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 means fewer server requests and less bandwidth. For apps with millions of users, this saves real money on infrastructure.
4. Emerging Market Accessibility
In regions with spotty connectivity or expensive data plans, offline-first PWAs let people use apps they otherwise could not access.
5. Better User Experience
Users expect apps to just work. Offline-first PWAs deliver on that by making online and offline feel the same.
When Offline-First Makes Sense
Offline-first architecture is ideal for:
- Content apps — News sites, docs, and blogs where content can be stored locally
- Productivity tools — Note apps, task managers, and calendars that sync later
- E-commerce — Product browsing works offline, checkout needs a connection
- Social feeds — Show cached posts while fetching new ones in the background
- Field service apps — Workers in areas without reliable internet
You probably do not need offline-first for:
- Real-time collaboration that needs constant connectivity
- Video streaming or large media files
- Apps where stale data causes real problems
Service Worker Fundamentals
Service workers are the foundation of offline-first PWAs. You need to understand how they work before picking a caching strategy.
What is a Service Worker?
A service worker is a JavaScript file that runs in the background, separate from your web page. It sits between your app and the network. When your app makes a request, the service worker decides what to do — serve from cache, fetch from the network, or run custom logic.
Key characteristics:
- Runs on its own thread — It will not block your UI
- Event-driven — It reacts to fetch events and lifecycle events
- HTTPS only — Required for security (localhost is exempt)
- No DOM access — It cannot touch the page directly
- Full cache control — You decide what to cache and when
Service Worker Lifecycle
Service workers go through four stages:
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 scope controls which requests a service worker can intercept. It is set by the 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 change sw.js, the browser installs a new version. The new worker waits until all tabs using the old one close before it takes over.
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 break things if old pages expect old assets. Use with care.
Core Caching Strategies
Each caching strategy makes a different trade-off between speed and freshness. The right choice depends on your content type.
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
Here is a quick reference for picking the right strategy:
| 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 one of the hardest problems in computer science. Here are proven ways to keep 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 caching works alongside other PWA features. You will also need a web app manifest — use our PWA manifest generator to create one.
Push Notifications
The same service worker file can handle both caching and push notifications:
// 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 the full setup guide, 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 tighter service worker restrictions:
- 7-day cache expiration — iOS deletes service worker caches after 7 days without use
- Storage quotas — iOS evicts cached data more aggressively than Chrome
- Background sync — Not supported on iOS
- Push notifications — Requires iOS 16.4+ and home screen install
Mitigation strategies:
- Prioritize most critical assets within storage limits
- Re-cache on app launch
- Educate users about home screen installation for full functionality
For more details, see Essential PWA Strategies for iOS and our complete guide to PWA iOS limitations.
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
Chrome, Edge, and Firefox on desktop all offer full service worker support with generous storage limits.
Choosing Between PWAs and Native Apps
Not sure if a PWA is enough for your use case? Our PWA vs native app guide helps you decide when PWA offline support is enough and when you need native development.
Conclusion
Service worker caching turns PWAs from fragile web pages into apps that work regardless of network conditions.
Key takeaways:
- Pick strategies by content type — Use cache-first for static assets and network-first for dynamic data.
- Test offline early and often — Use DevTools, throttling, and automated tests.
- Plan for cache invalidation — Use versioned cache names or time-based expiration.
- Monitor cache performance — Track hit rates and storage usage.
- Know the platform gaps — iOS Safari has tighter limits than Chrome.
With the right caching strategy, your PWA will load fast and work offline. Combine that with push notifications and you have a web app that rivals a native one.
Frequently Asked Questions
How much storage can service workers use?
It depends on the browser and your device's free disk space. Chrome allows up to 60% of available space. Use the Storage API to check:
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 you should not cache them. POST requests create or change data. Replaying them from cache would cause duplicate actions. Use the network-only strategy for POST, PUT, and 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 delete old cached data when storage fills up. They use LRU (Least Recently Used) eviction. Set your own cache size limits and expiration rules so you control what gets removed — not the browser.
Should I cache third-party resources?
Yes, but be careful. Cache fonts and libraries with cache-first for speed. But check cache-control headers and license terms first. Some CDNs do not allow 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. In production, you must use HTTPS. Service workers can intercept all network traffic, so browsers require encryption. Use Let's Encrypt for free SSL certificates.
How do offline-first PWAs compare to native apps?
For content-heavy apps, offline-first PWAs come close to native. But they still fall short on hardware access and background tasks. See our PWA vs native app comparison for a full breakdown.
