A Progressive Web App (PWA) is a web app that can be installed on a device like a native app and work without an internet connection. The gap between "website" and "PWA" is smaller than most people expect — the core requirements are just three things: HTTPS, a web app manifest, and a service worker.
This guide walks through all three, then covers the parts that trip people up in practice.
Step 1: The web app manifest
The manifest is a JSON file that tells the browser how to present your app when installed. Create public/manifest.json:
{
"name": "My App",
"short_name": "MyApp",
"description": "A short description of what your app does.",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#ffc233",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Link it in your HTML <head>:
<link rel="manifest" href="/manifest.json" />
display: "standalone" removes the browser chrome when installed — it looks like a native app. start_url controls which page opens on launch. Make sure it exists and loads fast.
For icons: you need at minimum 192×192 and 512×512 PNGs. The maskable icon is used by Android to apply platform-specific shapes (circle, squircle, etc.) — without it, Android adds a white circle around your icon. Generate maskable icons with maskable.app.
Step 2: Registering the service worker
A service worker is a JavaScript file that runs in a separate thread, intercepts network requests, and can serve responses from a cache when the network is unavailable.
Register it from your main JavaScript:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((err) => {
console.warn("Service worker registration failed:", err);
});
});
}
The registration should happen after the page loads — not before — to avoid competing with critical resources.
Step 3: The service worker and caching strategy
Create /public/sw.js. The caching strategy you choose determines how your app behaves offline.
Cache-first — serve from cache, fall back to network. Good for assets that rarely change (icons, fonts, vendor JS):
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached ?? fetch(event.request);
})
);
});
Network-first — try the network, fall back to cache. Good for HTML pages and API calls where stale data is worse than no data:
self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open("v1").then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});
Stale-while-revalidate — serve from cache immediately, update the cache in the background. Good for content that can be slightly stale but should eventually update.
For most apps: cache your app shell (HTML, CSS, JS) with cache-first, and use network-first or stale-while-revalidate for content and API data.
Pre-cache the app shell on service worker install:
const SHELL = ["/", "/index.html", "/app.js", "/styles.css", "/manifest.json"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("v1").then((cache) => cache.addAll(SHELL))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
skipWaiting() and clients.claim() ensure the new service worker activates immediately without waiting for old tabs to close.
The iOS quirks nobody warns you about
Safari on iOS has a different implementation of PWAs with several limitations that will surprise you:
No push notifications on iOS 15 and below. This was only added in iOS 16.4 and only for apps added to the home screen, not opened in Safari. Plan your onboarding around this.
Home screen addition is Safari-only. Users can only add a PWA to their home screen from Safari. If they open your app in Chrome for iOS, the install option doesn't exist. Make this clear in your install instructions.
Storage limits are lower on iOS. Safari can evict your service worker caches if the device is low on storage. Critical data should always be in IndexedDB, not just the cache.
standalone mode has no back button. When installed and opened in standalone mode, users don't have browser navigation. Your app needs its own navigation — a back button, breadcrumbs, or history management — for every flow that needs it.
Test on a real iOS device. The iOS Simulator does not faithfully replicate Safari's service worker behaviour. Install your app on a real iPhone and test offline mode by turning on airplane mode.
Verifying installability
Open Chrome DevTools → Application → Manifest. Chrome will show you any manifest errors. The Lighthouse PWA audit checks the full installability criteria — run it and fix everything it flags.
The install prompt on Android appears automatically once all criteria are met (HTTPS + manifest + service worker with a fetch handler). On iOS, it's always manual via the Share → Add to Home Screen flow.
What you get
A properly built PWA loads instantly on repeat visits (cached app shell), works offline or on slow connections, installs to the home screen without an app store, and gets a place on the user's device alongside native apps. For productivity tools, this meaningfully raises engagement — installed apps get launched; browser bookmarks get forgotten.
We build PWAs that work offline and install like native apps. If you're building something that needs to work on any device, anywhere — let's talk.