PWAs and service workers
Installable web apps that load fast and keep working offline: manifest, service worker, caching.
In 30 seconds
A Progressive Web App (PWA) is a website that can be installed like an app, opens in its own window, and keeps working on bad or no network. Two pieces make that possible: a web app manifest, which describes the app's name, icons and colours, and a service worker, a background script that sits between the page and the network and decides what to serve from cache. You keep one codebase and one URL, and add app-like behaviour on top.
Why I use it
- I built my own portfolio as an installable PWA, so I have worked through the manifest, icons and service worker details myself rather than only reading about them.
- I like that a PWA adds app-like behaviour to an ordinary website without app stores, separate codebases or review delays.
- A good service worker makes repeat visits fast and gives users a friendly offline page instead of the browser's error screen.
- It is a practical, low-cost way to improve perceived quality on mobile, where networks are slow or unreliable.
How it works
01The idea: a website that behaves like an app
A PWA is not a new technology but a set of browser features used together. Over HTTPS, a site with a valid manifest can be installed to the home screen or desktop, launch in a standalone window without browser chrome, and appear in the app switcher. A service worker adds offline support, faster loading through caching, and optionally push notifications and background tasks. Think of the service worker as a smart doorman: every request the page makes passes through it, and it decides whether to fetch from the network, answer from its cache, or both. 'Progressive' means it degrades gracefully: browsers that do not support a feature still get a normal website.
- Requires HTTPS (localhost is allowed for development).
- Manifest: identity and appearance when installed.
- Service worker: network proxy, cache and background work.
02The web app manifest
The manifest is a JSON file linked from the page. Key fields: name and short_name (shown under the icon), start_url (where the app opens), scope (which URLs belong to the app), display (standalone for an app window), theme_color and background_color (title bar and splash screen), and icons. Provide at least 192x192 and 512x512 PNG icons. Maskable icons, marked with purpose 'maskable', have their important content inside a central safe zone (a circle of 80% of the width), so Android can crop them to circles, squircles or other shapes without cutting off the logo. Add an id so the browser can identify the app even if start_url changes, and screenshots plus description for a richer install dialog.
{
"id": "/",
"name": "Example Portfolio",
"short_name": "Portfolio",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"theme_color": "#0f172a",
"background_color": "#0f172a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
03The service worker lifecycle
A service worker is a JavaScript file that runs in its own thread, without access to the DOM, and can outlive the page. The page registers it; the browser downloads it and fires install, where you typically pre-cache the offline page and core assets. Then it waits until no open tabs use the old worker, and fires activate, where you delete old caches. From then on it receives fetch events for every request within its scope. When you deploy a changed worker file, the browser detects the byte difference, installs the new version, and keeps it waiting until old tabs close. skipWaiting() and clients.claim() make a new version take over immediately, which is convenient but can mix old pages with new cached assets, so use them deliberately.
// In the page (only in production builds)
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" })
.catch((err) => console.error("SW registration failed", err));
});
}
04Caching strategies
Inside the fetch handler you choose a strategy per kind of request. Cache-first serves from cache and only goes to the network on a miss; ideal for fingerprinted static assets (app.3f9a.js) and fonts that never change. Network-first tries the network and falls back to cache when offline; ideal for HTML pages and API data that should be fresh. Stale-while-revalidate answers instantly from cache and refreshes the cache in the background; good for avatars and non-critical data. Network-only is for things that must never be cached, like authenticated mutations and payments. A common, safe default for content sites is network-first for navigations with an offline fallback page, and cache-first for hashed static files.
- Cache-first: hashed JS/CSS, fonts, icons.
- Network-first: HTML navigations, fresh API reads.
- Stale-while-revalidate: images, non-critical JSON.
- Network-only: POST requests, auth, checkout.
05A network-first service worker with an offline page
This hand-written worker pre-caches an offline page on install, removes old caches on activate, and handles page navigations network-first: it tries the network, stores a copy of successful pages, and falls back to the cached page or the offline page when the network fails. Non-GET requests and other origins are left alone. Bump the cache version when the offline page or core files change.
const CACHE = "site-v3";
const OFFLINE_URL = "/offline";
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll([OFFLINE_URL, "/icons/icon-192.png"])));
});
self.addEventListener("activate", (e) => {
e.waitUntil(caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))));
});
self.addEventListener("fetch", (e) => {
const req = e.request;
if (req.method !== "GET" || new URL(req.url).origin !== location.origin) return;
if (req.mode !== "navigate") return;
e.respondWith(fetch(req).then((res) => {
const copy = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(req, copy));
return res;
}).catch(async () => (await caches.match(req)) || caches.match(OFFLINE_URL)));
});
06PWAs in Next.js
Next.js App Router has first-class support for the manifest: an app/manifest.ts file returning a MetadataRoute.Manifest object is served as /manifest.webmanifest and linked automatically. Theme colour goes into the viewport export. The service worker is a static file in public/sw.js, served from the site root so its scope covers the whole app, and registered from a small client component. Add an offline route (app/offline/page.tsx) that does not depend on data so it can be pre-cached. Serve sw.js with Cache-Control: no-cache so browsers check for updates promptly; the Next.js docs show this via headers() in next.config. For larger apps, Serwist (the maintained successor of next-pwa, built on Workbox ideas) generates precache manifests from the build.
// app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Example Portfolio",
short_name: "Portfolio",
start_url: "/",
display: "standalone",
background_color: "#0f172a",
theme_color: "#0f172a",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
07Install, push and platform differences
Chromium browsers (Chrome, Edge, Samsung Internet) show an install prompt or menu entry when the site is on HTTPS and has a manifest with name, icons, start_url and a suitable display mode; you can capture the beforeinstallprompt event to show your own install button. Safari on iOS and iPadOS installs through Share, then Add to Home Screen, and since iOS 16.4 installed web apps can receive Web Push. Safari on macOS supports Add to Dock. Push needs a service worker, user permission requested after a user gesture, VAPID keys and a server that sends messages. Background Sync and Periodic Background Sync are Chromium-only, so treat them as enhancements. Firefox desktop does not install PWAs natively. Test on every target platform.
Use it when
- Content sites, portfolios and blogs that benefit from instant repeat visits and a friendly offline page.
- Internal tools and field apps used on unreliable networks, where offline reading or queued submissions matter.
- Products that want an installable, app-like experience without the cost of native apps and store reviews.
- E-commerce and media sites where faster loading on mobile directly affects conversions.
Skip it when
- Apps that need deep device integration (Bluetooth on iOS, background location, advanced camera control) that browsers do not expose.
- When discoverability through app stores is a core business requirement and a store-wrapped build is not acceptable.
- Highly personalised, always-fresh data where aggressive caching would show stale or another user's content.
Alternatives
- Native apps (Swift, Kotlin)
Full device access and store presence; separate codebases and release cycles.
- React Native / Expo
Shared JavaScript codebase for iOS and Android with native UI and store distribution.
- Capacitor
Wraps an existing web app in a native shell for app stores, with plugins for device APIs.
- Plain HTTP caching + CDN
Often enough for speed alone; no offline support or install.
Best practices
- Serve the service worker from the site root with Cache-Control: no-cache so updates are picked up quickly.
- Use network-first for HTML and cache-first only for fingerprinted assets, so users never get stuck on old pages.
- Version caches and delete old ones in activate; never let caches grow without limits.
- Provide both regular and maskable icons, and test them with a maskable-icon preview tool.
- Never cache authenticated or personalised responses in a shared cache key; skip non-GET requests entirely.
- Register the service worker only in production builds to avoid confusing caching during development.
- Audit with Chrome DevTools (Application panel) and Lighthouse, and test offline mode on real devices.
Common mistakes
- Caching HTML cache-first, so users keep seeing an old version of the site after a deploy.
- Serving sw.js with long cache headers or from a subfolder, which delays updates or limits its scope.
- Calling skipWaiting() blindly, so an old page suddenly runs against new cached assets and breaks.
- Using a non-maskable logo as the maskable icon, which gets cropped badly on Android.
- Asking for push notification permission on first page load, which users reflexively deny.
Deep dive
08Scope, navigation preload and performance
A service worker controls pages within its scope, which by default is the directory it is served from; that is why sw.js lives at the root. A page is only controlled after the worker activates, so the very first visit is never intercepted unless you call clients.claim(). One hidden cost of network-first is startup: when the worker is idle, the browser must boot it before the request can go out, adding tens of milliseconds. Navigation preload fixes this: the browser starts the navigation request in parallel with booting the worker, and the fetch handler uses event.preloadResponse. Keep the fetch handler fast and avoid an empty fetch listener, which adds overhead without benefit.
self.addEventListener("activate", (e) => {
e.waitUntil(self.registration.navigationPreload?.enable());
});
self.addEventListener("fetch", (e) => {
if (e.request.mode !== "navigate") return;
e.respondWith((async () => {
try {
return (await e.preloadResponse) || (await fetch(e.request));
} catch {
return (await caches.match(e.request)) || caches.match("/offline");
}
})());
});
09Safe update flows
The safest update pattern is: the new worker installs and waits; the page listens for a waiting worker and shows a 'new version available' banner; when the user accepts, the page posts a message, the worker calls skipWaiting(), and the page reloads on controllerchange. This avoids a running page suddenly being served by a worker whose cache holds different asset versions. For a content site with network-first HTML and hashed assets, automatic skipWaiting is usually fine, because old assets are not deleted mid-session if you keep the previous cache for a while. Always include a kill switch: deploying a worker that unregisters itself and clears caches is the emergency recovery if a bad worker ships.
// Emergency "kill switch" sw.js
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (e) => {
e.waitUntil((async () => {
for (const key of await caches.keys()) await caches.delete(key);
await self.registration.unregister();
for (const c of await self.clients.matchAll()) c.navigate(c.url);
})());
});
10Storage limits, eviction and offline data
The Cache API, IndexedDB and other origin storage share a quota managed by the browser; Chromium allows an origin up to a large fraction of free disk, Safari is stricter and may evict storage of sites not used for a while. By default storage is 'best effort' and can be cleared under pressure; navigator.storage.persist() requests persistent storage, which browsers grant based on engagement or installation. Check usage with navigator.storage.estimate(). For offline data beyond cached responses, such as drafts or queued form submissions, use IndexedDB (through a small wrapper like idb), and replay queued requests when back online, with Background Sync on Chromium and an online event fallback elsewhere. Design for conflicts: queued writes can be stale by the time they reach the server.
11Security and privacy pitfalls
Service workers are powerful, so browsers require HTTPS and same-origin scripts. The main risks are self-inflicted. Caching responses keyed only by URL can store one user's personalised page and show it to the next user on a shared device; never cache authenticated HTML or API responses in a generic cache, and clear caches on logout. Cross-origin opaque responses (no-cors) count heavily against quota and cannot be inspected for errors, so avoid caching them. An XSS bug that can register a worker would persist an attacker's code, so a strict Content Security Policy and worker-src directive help. Finally, a broken worker can lock users out of updates, which is why short HTTP caching on sw.js and a kill switch matter.
Real-world scenarios
Users still see the old site after a redesign
Problem
After a major redesign, some users report the old layout for days. A service worker was added a year ago with cache-first for all requests.
How to solve it
Ship a new sw.js that switches navigations to network-first, uses a new cache name, deletes all old caches in activate, and calls skipWaiting plus clients.claim so it takes over immediately. Make sure the web server sends Cache-Control: no-cache for sw.js, otherwise browsers keep the old worker longer. If the old worker is badly broken, deploy a kill-switch worker first that clears caches and unregisters, then reintroduce a correct worker in a later release. Verify in DevTools under Application, Service workers, using update on reload.
The Android icon looks tiny and boxed
Problem
After installing the PWA on Android, the home-screen icon appears as a small logo inside a white circle, unlike native apps around it.
How to solve it
The manifest only has regular icons, so Android shrinks them into a safe area on a white background. Create a dedicated maskable icon: a full-bleed 512x512 square in the brand background colour with the logo inside the central 80% circle. Add it to the manifest with purpose 'maskable', keeping the regular icons for other uses. Check it in a maskable icon preview tool and Chrome DevTools' manifest panel, then reinstall the app on the device, since launchers cache icons.
Field staff lose form data on bad connections
Problem
Inspectors fill in long forms in basements with no signal. When they submit offline, the request fails and the data is lost.
How to solve it
Save the form draft to IndexedDB on every change, so nothing is lost even if the tab closes. On submit, try the network; if it fails, add the submission to an outbox in IndexedDB with a client-generated UUID and show it as pending. Replay the outbox when connectivity returns: use Background Sync on Chromium and a fallback on the online event and app start elsewhere. The server treats the UUID as an idempotency key, so a submission replayed twice is stored once. Show clear status to the user for each pending item.
Cheat sheet
- Link the manifest (plain HTML)
<link rel="manifest" href="/manifest.webmanifest">- Next.js manifest file
app/manifest.ts exporting MetadataRoute.Manifest- Register a service worker
navigator.serviceWorker.register("/sw.js", { scope: "/" })- Maskable icon entry
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "purpose": "maskable" }- Header for sw.js
Cache-Control: no-cache, no-store, must-revalidate- Activate new worker now
self.skipWaiting(); self.clients.claim();- Check storage usage
await navigator.storage.estimate()- Ask for persistent storage
await navigator.storage.persist()- Debug in Chrome
DevTools > Application > Manifest / Service workers / Cache storage
In my real projects
- My own portfolio site, built with Next.js, is an installable PWA with a web app manifest.
- It ships maskable icons so the app icon looks right on Android's different icon shapes.
- Its service worker uses a network-first strategy and falls back to a dedicated offline page when there is no connection.
Questions & answers
Q1What makes a website a PWA?
Technically, three things: it is served over HTTPS, it has a web app manifest describing its name, icons, start URL and display mode, and it usually has a service worker that handles caching and offline behaviour. With those, browsers can offer installation, open it in a standalone window and keep it usable on poor networks. Beyond the checklist, a good PWA is responsive, fast on repeat visits, and handles offline gracefully with a clear message instead of a browser error. It remains a normal website with a URL, so it is indexable, shareable and updated instantly on deploy.
Q2How does a service worker update, and why do users sometimes see old content?
On navigation, the browser checks the service worker script for byte changes, at least every 24 hours. If it changed, the new worker installs but waits until all tabs controlled by the old worker are closed, then activates. Users see old content when HTML is served cache-first, when sw.js itself is cached by HTTP headers, or when the waiting worker never activates because the tab stays open. The fixes are network-first for pages, no-cache headers on sw.js, and either a 'new version available, reload' prompt that triggers skipWaiting, or careful use of skipWaiting with clients.claim.
Q3What is the difference between the Cache API and HTTP caching?
HTTP caching is controlled by response headers like Cache-Control and ETag, and the browser decides automatically when to reuse a response. The Cache API is a programmable store that service workers and pages control in code: you decide what to put in, when to read it, and when to delete it, regardless of headers. They stack: a service worker's fetch() still goes through the HTTP cache. In practice, HTTP caching handles most static asset speed, while the Cache API adds offline support and custom strategies like network-first with an offline fallback.
Q4What are maskable icons and why do they matter?
Android launchers display icons in different shapes, such as circles, squircles or rounded squares, depending on the device. A regular icon gets shrunk onto a white background or cropped awkwardly. A maskable icon is designed with its important content inside a central safe zone, a circle with a diameter of 80% of the icon, and fills the full square with background colour, so the launcher can crop it to any shape cleanly. You declare it in the manifest with purpose 'maskable', usually alongside a regular 'any' icon used in other contexts.
Q5Can PWAs send push notifications on iPhone?
Yes, since iOS and iPadOS 16.4, but only for web apps the user has added to the home screen. The flow uses the standard Push API and Notifications API with a service worker, and permission must be requested in response to a user action such as tapping a button. On the server you need VAPID keys and a library like web-push to send messages to the subscription endpoints. On Android and desktop Chromium, push works in the browser without installation. Respect users: ask only when the value is clear, and let them manage preferences.
Q6Should every website be a PWA?
Not every site needs installation, but almost every site benefits from a small part of it. A manifest with good icons costs little and improves how the site looks when shared or pinned. A simple service worker with an offline page and caching of static assets helps users on poor connections. Full offline-first behaviour, background sync and push are worth it only when users return often or work in unreliable conditions. The risk is caching mistakes that serve stale or private content, so a minimal, well-tested worker is better than an ambitious, buggy one.
Senior-level questions
S1How would you design an offline-first architecture for a data-entry PWA?
I would treat the local database as the primary source for the UI: IndexedDB holds records and an outbox of pending mutations, each with a client-generated UUID and timestamp. The UI reads and writes locally and shows sync status. A sync engine pushes the outbox when online, using the UUIDs as idempotency keys, and pulls server changes since a cursor. Conflicts need an explicit policy per entity: last-write-wins for simple fields, field-level merges, or flagging for manual resolution where data matters. The service worker precaches the app shell and handles navigations; the data layer lives in the app, not the worker, so it is testable. Storage persistence is requested, and the server validates everything because clients can be stale or tampered with.
S2When would you use Workbox or Serwist instead of a hand-written service worker?
A hand-written worker is fine for a small, clear policy: precache an offline page, network-first for navigations, cache-first for hashed assets. It is a few dozen lines and easy to audit. Once you need a build-generated precache manifest with revision hashes, multiple runtime routes with expiration and size limits, broadcast updates, background sync queues and navigation preload, a library pays off because these details are easy to get subtly wrong. Workbox provides those as modules; Serwist is a maintained fork with good Next.js integration. The trade-off is another dependency and abstraction to understand when debugging. I start simple and switch when the routing table grows beyond a handful of rules.
S3How do PWAs affect SEO and Core Web Vitals?
A PWA is still a website, so indexing depends on the HTML the server returns, not the service worker; crawlers generally do not use your worker. Server-rendered or static pages remain essential for SEO. For Core Web Vitals in the field, a service worker helps repeat visits: cached assets improve LCP, and an app shell can make navigations near-instant. But a poorly written worker can hurt: booting the worker adds latency to every navigation unless navigation preload is enabled, and network-first with slow timeouts can delay pages on flaky networks. I measure real user data segmented by first and repeat visits, enable navigation preload, and keep the worker's fetch logic minimal.
Glossary
- PWA
- Progressive Web App: a website with a manifest and service worker that can be installed and work offline.
- Web app manifest
- A JSON file describing an app's name, icons, colours, start URL and display mode for installation.
- Service worker
- A background script that intercepts the page's network requests and can cache, modify or answer them.
- Scope
- The URL path a service worker controls; by default the folder the worker file is served from.
- Maskable icon
- An icon with content inside a central safe zone so platforms can crop it to any shape.
- Cache API
- A browser storage API for saving and retrieving request and response pairs from code.
- Network-first
- A caching strategy that tries the network and falls back to cached content when offline.
- Standalone display
- Manifest mode where the installed app opens in its own window without browser address bar.
- VAPID
- Keys that identify your server to browser push services when sending Web Push messages.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.