Transform web applications into installable, offline-capable experiences with Service Workers, Web App Manifests, and caching strategies. Covers cache patterns, background sync, push notifications, and the performance benefits that make PWAs competitive with native apps.
Progressive Web Apps extend the reach of web applications into environments where network connectivity is intermittent, absent, or expensive. This pattern matters when you need a web application to present useful content or complete critical workflows without a live connection—for example, a field-service interface in a basement, a kiosk in a dead-zone venue, or a consumer-facing app on a commute. Offline capability is not magic; it is a deliberate layering of cache, service worker, and manifest configuration that intercepts network requests and serves stored responses.
The entry point is the service worker registration. A service worker is a script that runs in the browser background, separate from the main thread, and can intercept fetch events, manage cache storage, and respond to sync events. Registration happens early in the page lifecycle, typically at the bottom of your entry point:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", {
scope: "/"
}).then((reg) => {
console.log("SW registered: ", reg);
}).catch((err) => {
console.log("SW registration failed: ", err);
});
});
}
The scope parameter determines the service worker’s jurisdictional boundary. Requests whose URLs fall within the scope are handled by the worker; requests outside it pass through to the network unmolested. A common misstep is setting scope too narrowly—e.g., /app/—which leaves assets under /static/ or /api/ unmanaged, producing the silent failure where the app loads but resources fail.
Before the service worker can do its job, the browser needs to know the app’s identity. The manifest is a JSON file at the site root, conventionally named manifest.json:
{
"short_name": "Notes",
"name": "Notes PWA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{"src": "icons/icon-72x72.png", "type": "image/png", "sizes": "72x72"},
{"src": "icons/icon-96x96.png", "type": "image/png", "sizes": "96x96"},
{"src": "icons/icon-128x128.png", "type": "image/png", "sizes": "128x128"},
{"src": "icons/icon-144x144.png", "type": "image/png", "sizes": "144x144"},
{"src": "icons/icon-152x152.png", "type": "image/png", "sizes": "152x152"},
{"src": "icons/icon-192x192.png", "type": "image/png", "sizes": "192x192"},
{"src": "icons/icon-384x384.png", "type": "image/png", "sizes": "384x384"},
{"src": "icons/icon-512x512.png", "type": "image/png", "sizes": "512x512"}
]
}
The display: standalone value removes the browser chrome, launching the app in its own window. Omitting the manifest or serving it with the wrong Content-Type (not application/manifest+json or text/cache-manifest) means the browser never prompts to "Add to Home Screen," and the standalone mode never activates. The start_url must resolve to a route that registers the service worker; otherwise, the app launches but offline behavior is never installed.
Not every resource should be treated the same. The pattern you select determines what survives a network outage and what requires a refresh.
Cache-first is appropriate for static assets that change infrequently:
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
if (!event.request.url.startsWith(self.registration.scope)) return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) return cachedResponse;
return fetch(event.request).then((networkResponse) => {
// Optional: cache the network response for future offline use
const copy = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, copy);
});
return networkResponse;
}).catch(() => {
// If both cache and network fail, return offline fallback
return caches.match("/offline.html");
});
})
);
});
Network-first suits volatile data—API responses, real-time feeds—where stale data is worse than no data:
event.respondWith(
fetch(event.request).then((networkResponse) => {
const copy = networkResponse.clone();
caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(event.request, copy);
});
return networkResponse;
}).catch(() => {
return caches.match(event.request).then((cached) => {
return cached || new Response("Offline, try again later.", { status: 503 });
});
})
);
Stale-while-revalidate is the default for most content: serve from cache immediately, update in background.
event.respondWith(
caches.match(event.request).then((cached) => {
const cachePromise = caches.open(DYNAMIC_CACHE).then((cache) => {
fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
});
});
return cached || cachePromise.then((latest) => latest);
})
);
Your caching strategy should reflect the semantics of each resource type.
HTML documents should rarely be cached long-term. Navigation requests typically benefit from a network-first or stale-while-revalidate approach, with an explicit fallback for offline:
// In the fetch handler for document requests
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).then((resp) => {
const copy = resp.clone();
caches.open(NAV_CACHE).then((c) => c.put(event.request, copy));
return resp;
}).catch(() => caches.match("/offline.html"))
);
}
CSS and JavaScript are versioned assets. Append a content hash to filenames (e.g., main.a1b2c3d4.css) and cache them cache-first. When the hash changes, the browser fetches the new version and the old one naturally expires.
Images vary. Hero images on a landing page can use cache-first. User-uploaded galleries should use stale-while-revalidate with a size limit on the cache (e.g., keep the last 50 entries, delete older ones).
API data almost always warrants network-first or an explicit offline error. Caching API responses risks serving stale business data, which is worse than a graceful degradation message.
When the network drops mid-workflow—say, a user composes a note offline—background sync can queue the action and transmit it once connectivity returns. The Service Worker API provides SyncManager:
// In the service worker
async function registerSyncTag(tag) {
if (!("sync" in registration)) return;
try {
await registration.sync.register(tag);
console.log(`Background sync registered for: ${tag}`);
} catch (err) {
console.error(`Background sync failed for: ${tag}`, err);
}
}
// Later, from the app shell
if ("serviceWorker" in navigator && "sync" in navigator.serviceWorker) {
navigator.serviceWorker.ready.then((registration) => {
registration.sync.register("send-notes").then(() => {
console.log("Sync registered");
});
});
}
The tag "send-notes" is your choice; it must be unique within the origin. The sync run is bounded by a timeout (typically 24 hours) and requires the user to keep the page open or the browser running. A common silent failure: the sync registration succeeds but never fires because the browser throttles background tasks when the battery is low or the system is in power-save mode.
The fastest way to verify offline logic is the DevTools Application panel:
⌘+Option+I on macOS, Ctrl+Shift+I on Windows).Alternatively, use curl with the --interface flag to simulate no route to host, or employ network-link-conditioner if you have it available:
# Simulate offline by blocking network interface (requires root/privileges)
sudo ip link set down dev wlan0
# Or simply test with the browser devtools offline mode
When the offline checkbox is checked, every fetch event fires. Watch the Network panel: requests should either hit the cache (status (from cache)) or fall through to your fallback response. A frequent confusion: the page appears to load offline, but the console shows NETWORK_ERR for individual assets. This usually means the asset’s URL falls outside the service worker’s scope, or the manifest wasn’t fetched, or the Service-Worker navigation preload header isn’t set for document requests.
Scope creep. If your app lives at https://example.com/app/, registering the service worker at / gives it jurisdiction over the entire origin. This is often desirable but can mask bugs where a fetch for /api/users is intercepted when it shouldn’t be. Conversely, registering at /app/ means requests to /static/js/bundle.js are never cached unless you also configure a broader scope or additional entries.
Cache invalidation is your responsibility. caches.match() returns the first matching entry. If you never delete old entries, the cache grows unbounded. There’s no built-in TTL; you must programmatically cache.delete() entries or limit size in caches.open(DYNAMIC_CACHE).then((cache) => { /* purge old keys */ }). A production outage once occurred because a service worker cached 200 MB of image assets from a single page view, evicting critical API responses.
Mixed content. If your page loads over HTTPS but the service worker registration attempts to fetch over HTTP, the registration is blocked. All network requests intercepted by the worker must originate from a secure context. This is easy to miss when developing locally with http://localhost and then deploying to HTTPS.
The “offline” illusion. Caching an HTML fallback (/offline.html) does not make the app functional offline. That HTML can reference CSS, images, or API calls that themselves fail without network. The fallback must be self-contained or reference only cached resources. Many teams ship an offline page that looks broken because the embedded assets weren’t also cached.
Background sync requires user gesture. On iOS, background sync is not supported at all. On Android, it works only if the browser is in the foreground or the user recently interacted with the device. If your app targets a global audience, assume background sync is a progressive enhancement, not a reliability guarantee.
Versioning the service worker. The browser compares the registered SW’s hash against the previous registration. If you update the SW script (e.g., change caching logic) without bumping the version, some browsers won’t activate the new SW until the page is closed and reopened. A common pattern is to include a precache manifest hash or use workbox.prefix strategies that auto-hash filenames, but if you’re managing SW registration manually, you must track version changes explicitly.
After deploying your manifest and service worker, you can verify registration with a single curl against the page, then inspect the result:
curl -I https://example.com/
Check the Service-Worker header if your hosting config pushes it, or simply open the DevTools Application panel as described above. If you need to test the manifest specifically:
curl -I https://example.com/manifest.json
Should return Content-Type: application/manifest+json. A 200 OK with text/html means the browser ignores it.
Offline-capable web applications require three coordinated pieces: a web app manifest that declares the app’s identity, a service worker script that intercepts and caches network requests, and a caching strategy tuned to each resource class. The mechanics are standard—the Cache Storage API, the Fetch handler, the SyncManager—but the devil is in the scope boundaries, the invalidation policy, and the fallback self-containment. Build, test, and iterate with the DevTools offline mode, and treat cache growth as a first-class concern rather than an afterthought.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy