Offline-First PWA Patterns That Users Love
Cache-first, network-first, stale-while-revalidate — the service worker strategies that make PWAs work without a network, plus app shell, background sync, and offline UX.
Published on • August 10, 2026
AI Assistant

Building offline apps used to mean native development with complex sync logic. Progressive web apps changed that. A service worker acts as a network proxy that sits between your app and the internet, intercepting requests and deciding how to respond — from cache, from the network, or both. Done right, the app loads instantly on a fast connection, survives a flaky train connection, and keeps working with the radio completely off.
In this post, you will learn the service worker lifecycle, the five core caching strategies, how to pick the right strategy per resource, and the offline UX patterns that keep users productive instead of stranded.
The service worker lifecycle
A service worker is a JavaScript file that runs in the background, independent of the page. It intercepts network requests, manages caches, and enables offline functionality. The lifecycle is install → activate → fetch.
// sw.js
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = ['/', '/index.html', '/app.css', '/app.js'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(names => Promise.all(
names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n))
))
);
self.clients.claim();
});
The install event pre-caches the critical shell. The activate event cleans up old cache versions — this is how you invalidate stale caches: bump CACHE_NAME on every release and the old cache gets deleted on the next activation.
The five caching strategies
Each strategy balances freshness against speed differently. Pick per resource type:
- Cache-first — serve from cache if available, fetch and cache otherwise. Fastest, used for static assets that rarely change.
- Network-first — try the network, fall back to cache on failure. Fresh, with offline resilience. Used for HTML pages and API data.
- Stale-while-revalidate — serve the cached copy immediately, fetch fresh in the background, update the cache. The best of both for content that can be slightly stale.
- Network-only — never cache. Used for mutations (POST/PUT) and real-time data where stale is worse than nothing.
- Cache-only — only ever serve from cache. Used for the pre-cached app shell.
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then(response => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => cached);
return cached || fetchPromise;
}
Choosing the right strategy per resource
| Content type | Strategy | Why |
|---|---|---|
| App shell (HTML/CSS/JS) | Cache-first | Static shell should load instantly |
| Images, fonts | Cache-first | Rarely change, large files benefit |
| API data | Network-first or stale-while-revalidate | Depends on staleness tolerance |
| User avatars | Stale-while-revalidate | Slightly stale is fine, updates in background |
| Real-time data | Network-only | Stale data is worse than no data |
| POST/PUT requests | Network-only | Mutations must reach the server |
For a route-based setup, match URL patterns to strategies:
self.addEventListener('fetch', event => {
const { request } = event;
const url = request.url;
let strategy;
if (/\.(png|jpg|jpeg|svg|webp|woff2)$/.test(url)) strategy = cacheFirst;
else if (/\/api\//.test(url) || url.endsWith('.html')) strategy = networkFirst;
else if (/\/feed\//.test(url)) strategy = staleWhileRevalidate;
else strategy = networkOnly;
event.respondWith(strategy(request));
});
The app shell pattern
The app shell is the minimal set of UI resources — the HTML frame, CSS, JS, and icons — that you pre-cache so the app frame loads fast and offline. Dynamic content changes often and gets its own strategy. For navigation requests, the robust pattern is: try the network first (to get the latest shell), fall back to the cached shell when offline.
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
const networkRes = await fetch(event.request);
const cache = await caches.open('shell');
cache.put('/', networkRes.clone());
return networkRes;
} catch (err) {
const cache = await caches.open('shell');
const cached = await cache.match('/');
return cached || cache.match('/offline.html');
}
})()
);
}
});
The offline fallback page
When a navigation fails entirely and there’s no cached page, serve a dedicated /offline.html instead of the browser’s error. It keeps the brand visible, explains the situation, and offers a retry button. Pre-cache it in the install step so it’s always available.
Background sync: queue writes, not errors
For offline writes, the Background Sync API defers mutations until connectivity returns. Actions taken offline are queued and replayed when the network comes back — no lost data, no error screens.
// Register a sync event for queued offline actions
self.addEventListener('sync', event => {
if (event.tag === 'queue-actions') {
event.waitUntil(replayQueuedActions());
}
});
Combine this with optimistic UI — show the action as done immediately, roll back if the sync ultimately fails.
Use Workbox instead of hand-rolling
Writing service workers by hand is tedious and error-prone. Workbox provides tested, composable caching strategies out of the box, and the vite-plugin-pwa generates your service worker, web app manifest, and precache manifest from your build config — the fastest path to a production-ready PWA.
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-responses', networkTimeoutSeconds: 3 },
},
],
},
}),
],
});
Offline UX: tell the user what’s happening
Patterns that make offline feel intentional rather than broken:
- Connectivity indicators — a banner showing “You’re offline” with an
navigator.onLinelistener. - Graceful degradation — hide network-only features when offline, keep cached content first-class.
- Storage awareness — monitor
navigator.storage.estimate()and request persistent storage for critical apps so the browser doesn’t evict your caches. - Update notifications — when a new service worker installs, prompt the user to refresh to pick up the new version.
Putting It All Together
A complete offline-first PWA: precache the app shell with a cache-first strategy, serve API reads with network-first (3-second timeout), sync writes via Background Sync with optimistic UI, and drop in an /offline.html fallback. Wire it up with vite-plugin-pwa, test in Chrome DevTools with the Network panel set to Offline, and verify the shell loads with the radio off.
Conclusion & Next Steps
You now understand the service worker lifecycle, the five caching strategies and when to use each, the app shell pattern, and the offline UX patterns that keep users productive. Next steps: adopt Workbox (or vite-plugin-pwa) to eliminate boilerplate, add Background Sync for your write endpoints, and measure your storage usage so you can request persistence.
References / Sources
- web.dev — Progressive Web Apps guide. https://web.dev/articles/progressive-web-apps
- MDN — Service Worker API and CacheStorage. https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage
- Workbox — production-ready service worker libraries. https://developer.chrome.com/docs/workbox
- vite-plugin-pwa. https://vite-pwa-org.netlify.app/
- Background Sync API. https://developer.mozilla.org/en-US/docs/Web/API/Background_Sync_API