Service Worker Caching Strategies for Share PWAs
This guide is part of Offline-First PWA Patterns for Web Share API.
A share-capable PWA has a harder caching problem than a content site. It must render its share button and queue UI with zero network, serve the landing route that a Web Share Target hands off to, and do all of this without ever caching the one-time POST that carries an inbound share. Choosing one blanket strategy for every request is the mistake that produces blank landing pages, stale shells after deploy, and shares that appear to succeed but replay old data. This guide maps each request type a share PWA handles to the correct caching strategy and shows the service worker lifecycle that keeps them coherent.
The offline pillar introduces the install-time precache and a two-branch fetch handler; this guide deepens that into a full request-routing decision and the activate-time cleanup that a versioned cache demands.
Problem Framing
A service worker intercepts every network request the PWA makes. If it answers all of them the same way, one of two failures follows. Answer everything cache-first and a deployed bug fix never reaches users, because the worker keeps serving the stale copy it already holds. Answer everything network-first and the share UI hangs behind a spinner the moment the device drops offline — the exact moment an offline-first share app exists to survive.
Share PWAs add three request classes that a generic caching recipe ignores:
- The share-UI shell — the HTML, CSS, JS, and icons that draw the share button and the queued-share list. This must render instantly and offline, so it wants cache-first backed by a precache.
- Share-landing routes — the GET route a Web Share Target redirects to after receiving content, plus the metadata it renders (link previews, shared-item lists). These are dynamic but tolerate slight staleness, making them candidates for network-first or stale-while-revalidate.
- The inbound share POST — the request a registered share target receives. This is one-time form data that must never be cached; the Cache API refuses to store it anyway.
The rest of this guide routes each class deliberately and keeps the caches coherent across deploys.
Prerequisites Checklist
Browser Support Snapshot
| Browser / Platform | Service Worker | Cache API | Notes |
|---|---|---|---|
| Chrome (Android) | ✅ v40+ | ✅ | Full lifecycle, reliable activate cleanup |
| Chrome / Edge (desktop) | ✅ | ✅ | Inherits Chromium behaviour |
| Safari iOS 16.4+ | ✅ | ✅ | Caches can be evicted aggressively under storage pressure; do not assume persistence |
| Safari macOS | ✅ | ✅ | 7-day eviction for unused origins |
| Firefox (desktop/Android) | ✅ | ✅ | No navigator.share, but caching works — pair with clipboard fallback |
| Samsung Internet | ✅ | ✅ | Matches Chromium |
The service worker platform is broadly available. The iOS quirk that matters here is eviction, not absence: WebKit purges Cache Storage for origins it considers idle and under storage pressure, so treat the precache as reconstructible, never as durable storage. Durable share payloads belong in an IndexedDB share queue, not in the response cache.
Step-by-Step Implementation
Step 1 — Define the shell asset list
The shell is the smallest set of files that lets the share UI paint and accept input with no network. Keep it minimal: every entry is a file the browser must download and store before the worker activates, and one wrong path aborts the whole precache.
// sw-config.js — single source of truth for versioning and the shell
export const CACHE_VERSION = 'v7';
export const SHELL_CACHE = `share-shell-${CACHE_VERSION}`;
export const RUNTIME_CACHE = `share-runtime-${CACHE_VERSION}`;
// Only what the share button + queue UI need to render offline.
export const SHELL_ASSETS = [
'/',
'/index.html',
'/assets/css/main.css',
'/assets/js/share-ui.js',
'/assets/js/share-queue.js',
'/assets/icons/share-192.png',
'/offline.html'
];
Deriving the cache names from a single CACHE_VERSION is what makes Step 4’s cleanup a one-line comparison. Bumping the version renames every cache at once, so the new worker precaches into fresh storage while the old caches become orphans ready to delete.
Step 2 — Precache the shell on install
The install event runs once per worker version. Wrap the precache in event.waitUntil so the worker is not considered installed until every shell asset is stored.
// sw.js — install
import { SHELL_CACHE, SHELL_ASSETS } from './sw-config.js';
self.addEventListener('install', function onInstall(event) {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
// addAll is atomic: any single 404 rejects the whole install.
await cache.addAll(SHELL_ASSETS);
})()
);
});
cache.addAll is atomic by design — if any request fails, none are stored and the worker never activates, so users are never left with a half-cached shell. The failure modes of this step (a stale path, over-precaching) are covered in depth in precaching the app shell for offline sharing.
Step 3 — Route fetches by request type
This is the core of the strategy. Branch first on method (a POST is a share target hand-off — never touch the cache), then on request destination. Each branch delegates to a small strategy helper.
// sw.js — fetch routing
import { SHELL_CACHE, RUNTIME_CACHE } from './sw-config.js';
self.addEventListener('fetch', function onFetch(event) {
const { request } = event;
// 1. Share-target POST: one-time body, never cacheable. Let it pass through.
if (request.method !== 'GET') return;
const url = new URL(request.url);
// 2. Share-landing metadata (link previews, shared item lists): SWR.
if (url.pathname.startsWith('/api/share-preview')) {
event.respondWith(staleWhileRevalidate(event));
return;
}
// 3. Navigation requests (share-landing HTML, app routes): network-first.
if (request.mode === 'navigate') {
event.respondWith(networkFirst(event));
return;
}
// 4. Static shell + versioned assets: cache-first.
event.respondWith(cacheFirst(event));
});
async function cacheFirst(event) {
const cached = await caches.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
const cache = await caches.open(SHELL_CACHE);
cache.put(event.request, response.clone());
return response;
}
async function networkFirst(event) {
try {
const fresh = await fetch(event.request);
const cache = await caches.open(RUNTIME_CACHE);
cache.put(event.request, fresh.clone());
return fresh;
} catch {
const cached = await caches.match(event.request);
return cached || caches.match('/offline.html');
}
}
async function staleWhileRevalidate(event) {
const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(event.request);
const network = fetch(event.request)
.then((response) => {
cache.put(event.request, response.clone());
return response;
})
.catch(() => cached);
event.waitUntil(network);
return cached || network;
}
Note that staleWhileRevalidate returns the cached copy immediately while event.waitUntil keeps the worker alive to finish the background update — the pattern is expanded in stale-while-revalidate for shared content. The POST early-return is what keeps an inbound share from ever reaching cache.put, which would throw.
Step 4 — Clean up old caches on activate
activate fires when the new worker takes over. Delete every cache whose name does not match the current version, then claim open clients so they route through the new worker without a reload.
// sw.js — activate
import { SHELL_CACHE, RUNTIME_CACHE } from './sw-config.js';
const CURRENT_CACHES = new Set([SHELL_CACHE, RUNTIME_CACHE]);
self.addEventListener('activate', function onActivate(event) {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((name) => !CURRENT_CACHES.has(name))
.map((name) => caches.delete(name))
);
await self.clients.claim();
})()
);
});
Without this cleanup, every deploy leaks a full copy of your shell into a new versioned cache and old ones accumulate until the browser evicts the entire origin under quota pressure — taking your live caches with it. Filtering by the current version set is why Step 1 centralised the names.
Step 5 — Ship a controlled update flow
A new sw.js installs in the background and waits. Do not call skipWaiting() unconditionally: swapping assets mid-session can load new JS against a page rendered by old HTML. Detect the waiting worker, tell the user, and activate on their confirmation.
// app-update.js — foreground update detection
export async function watchForUpdate(onUpdateReady) {
if (!('serviceWorker' in navigator) || !window.isSecureContext) return;
const registration = await navigator.serviceWorker.register('/sw.js');
registration.addEventListener('updatefound', () => {
const incoming = registration.installing;
if (!incoming) return;
incoming.addEventListener('statechange', () => {
if (incoming.state === 'installed' && navigator.serviceWorker.controller) {
onUpdateReady(() => incoming.postMessage({ type: 'SKIP_WAITING' }));
}
});
});
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
}
// sw.js — respond to the user's confirmation
self.addEventListener('message', function onMessage(event) {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
This inverts control: the worker only skips waiting when the foreground explicitly asks, and the single controllerchange reload guard prevents the infinite refresh loop that unconditional skipWaiting plus clients.claim can trigger.
Payload Validation and Error Boundary
Two failures dominate caching code, and both are silent unless you guard for them. First, cache.put rejects with a TypeError for any non-GET request or any response with a non-basic/cors type (for example, an opaque no-cors response with status 0). Second, storing a partial 206 response corrupts range requests on replay. Validate before you cache:
// cache-guards.js
export function isCacheable(request, response) {
if (request.method !== 'GET') return false; // share POSTs and mutations
if (!response || response.status !== 200) return false; // skip 206, 3xx, 4xx, 5xx
if (response.type === 'opaqueredirect') return false;
return true;
}
export async function safePut(cacheName, request, response) {
if (!isCacheable(request, response)) return;
const cache = await caches.open(cacheName);
await cache.put(request, response.clone());
}
Wrap every cache.put from Step 3 in safePut. A share-target POST that slips past the method guard, an auth redirect, or a Range-request media response will otherwise throw inside the fetch handler and reject the whole respondWith, turning a cacheable-miss into a failed navigation.
Platform Gotchas
iOS Safari eviction. WebKit evicts Cache Storage for origins it deems unused, and does so far more aggressively than Chromium. Never treat a precached asset as guaranteed-present; the cache-first helper in Step 3 correctly falls through to fetch on a miss, which is what saves you here. Durable data belongs in a share queue backed by IndexedDB, which has stronger persistence guarantees when combined with navigator.storage.persist().
Android Chrome navigation preload. For network-first navigations you can enable registration.navigationPreload to start the network fetch in parallel with worker boot, shaving the worker startup cost off the critical path. It is Chromium-only; guard the enable() call with a feature check in activate.
Desktop network-first false positives. fetch resolves for a 500 or 404 — it only rejects on a true network failure. Your network-first branch will happily cache and serve a 500 error page unless you check response.ok. The safePut guard above rejects non-200 responses for exactly this reason.
Share-target POST and the Cache API. A registered share target is invoked with a POST. Because the method guard returns early before any cache logic, the POST reaches your handler untouched — read its formData(), persist the payload, and redirect to a cacheable GET landing route. Caching the POST is impossible by spec, and attempting it throws; see receiving shared files and text for the full receive flow.
Testing and Verification
DevTools checklist (Chromium):
- Application → Service Workers: confirm the worker is
activated and running. Tick Update on reload while iterating so each save installs a fresh worker. - Application → Cache Storage: expand your
share-shell-vNcache and verify everySHELL_ASSETSentry is present. After a version bump, confirm the oldshare-shell-v(N-1)cache is gone — proof thatactivatecleanup ran. - Application → Service Workers → tick Offline (or Network → throttling → Offline). Reload: the shell must paint. Navigate to a share-landing route you visited while online; network-first must serve the cached HTML, and an unvisited route must fall back to
/offline.html. - Network panel: reload online and confirm shell assets report
(ServiceWorker)as their source, and that a share-preview request shows two entries over time (an instant cache hit plus a background revalidation) for the stale-while-revalidate route.
Physical device checklist:
FAQ
Should I cache the POST request that a share target receives?
No. The Cache API only stores GET requests, and calling cache.put with a POST request throws a TypeError. A share target POST carries one-time form data that must be read from the request body and handed to app state, never replayed from cache. Cache the GET landing route that renders the receiving UI instead, as shown in receiving shared files and text.
Does Cache-Control affect what the service worker caches?
Not directly. Once a response is inside the Cache API, the service worker serves it verbatim and ignores its Cache-Control freshness rules — expiry logic is entirely up to your code. Cache-Control still governs the browser HTTP cache that the worker’s own fetch reads through, so serving sw.js with no-cache is what lets a new deploy be discovered on the next navigation.
Why does my share button work offline but the shared content page is blank?
The app shell is precached but the share-landing route that renders the received payload is not. Precaching covers the static UI; the dynamic landing route needs a runtime caching rule — network-first or stale-while-revalidate — so its HTML and data survive a repeat offline visit. Add a route branch for it in the Step 3 fetch handler.
Is stale-while-revalidate safe for share metadata?
It is ideal for non-critical, frequently read data such as link previews and shared-item lists, where an instantly rendered slightly stale value beats a spinner. It is wrong for anything that must be exact at read time — auth state, a share’s delivery status — because the user acts on the stale copy before the background revalidation lands. The tradeoffs are detailed in stale-while-revalidate for shared content.
How do I force clients onto a new cache version after deploy?
Bump the version string in the cache name so install precaches into a fresh cache, delete non-matching caches in the activate handler, then call clients.claim() so open tabs are controlled by the new worker. Gate skipWaiting() behind a user prompt, as in Step 5, to avoid swapping assets under an active session.