Web8 min read
PWA Offline Support: Build an Installable Web App
Build a PWA with offline support: web app manifest, service worker caching strategies, a custom install prompt and the iOS caveats that still catch people out.
A Progressive Web App with offline support needs three things: a web app manifest that describes how the app installs, a service worker that intercepts network requests, and a caching strategy that decides what gets served when the network is gone. Get those right and your site loads instantly on repeat visits, works on a flaky connection, and can be installed to the home screen on Android, desktop and iOS.
I have shipped two offline-first PWAs: a live-score site for the Nepal Premier League and Dashain Games, a set of browser games that people install and play without a connection. This guide is the setup I keep coming back to, written with plain JavaScript so you can drop it into any stack.
Key takeaways
- A PWA is a normal website plus a manifest, a service worker and HTTPS. No framework is required.
- Pick a caching strategy per request type: cache-first for hashed assets, network-first for pages, stale-while-revalidate for data.
- Always ship an offline fallback page so navigations never end in the browser's error screen.
beforeinstallpromptonly exists in Chromium browsers. Safari and Firefox users install from the browser menu.- Since Safari 26, any site added to the iOS Home Screen opens as a web app by default, but storage limits and the missing install prompt still need planning.
The web app manifest
The manifest is a JSON file linked from your HTML. Browsers read it to decide the app name, icon, colours and launch behaviour.
{
"id": "/",
"name": "Score Tracker",
"short_name": "Scores",
"description": "Live scores that keep working offline.",
"start_url": "/?source=pwa",
"scope": "/",
"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/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{ "src": "/screens/home.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }
]
}Link it in the <head>, along with an Apple touch icon because iOS still prefers it for the Home Screen icon:
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />According to web.dev's install criteria, Chrome wants name or short_name, a 192px and a 512px icon, a start_url, a display of standalone, fullscreen, minimal-ui or window-controls-overlay, and prefer_related_applications absent or false. It also requires HTTPS and a little user engagement (at least one click or tap and about 30 seconds on the page) before it fires the install event.
A few details that matter more than they look:
idgives the app a stable identity. Without it, changingstart_urllater can make browsers treat it as a different app.- Maskable icons need the important part of the artwork inside the central safe zone, or Android will crop your logo.
screenshotsenable the richer install dialog on Chromium. Without them you get the small, plain prompt.
Registering the service worker
Register after the page loads so the worker's own downloads do not compete with your first render.
// main.js
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
console.log("SW registered", reg.scope);
} catch (err) {
console.error("SW registration failed", err);
}
});
}Serve sw.js from the root of your site. A service worker can only control pages at or below its own path, so /js/sw.js would only control /js/. Also set Cache-Control: no-cache on sw.js itself. Browsers check for updates on navigation, but a long HTTP cache on that file slows down how fast fixes reach users.
Service worker caching strategies
This is where most of the real decisions live. Here is how I map request types to strategies:
| Request type | Strategy | Why |
|---|---|---|
Hashed JS, CSS, fonts (app.3f9a.js) |
Cache-first | The filename changes when content changes, so a cached copy is never stale |
| HTML navigations | Network-first, offline fallback | Users should see fresh content when online, something useful when offline |
| API data (scores, feeds) | Stale-while-revalidate | Show the last known data instantly, refresh in the background |
| Images | Cache-first with a size limit | Large and rarely change, but can fill storage fast |
| POST requests, auth, payments | Network-only | Never cache writes or sensitive responses |
A complete sw.js
This is a working service worker with no build step. It precaches an app shell, picks a strategy per request, and cleans up old caches on activation.
// sw.js
const VERSION = "v12";
const SHELL_CACHE = `shell-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;
const OFFLINE_URL = "/offline.html";
const SHELL_ASSETS = ["/", OFFLINE_URL, "/styles.css", "/main.js", "/icons/icon-192.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key !== SHELL_CACHE && key !== RUNTIME_CACHE)
.map((key) => caches.delete(key))
);
await self.clients.claim();
})()
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // let writes go straight to the network
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirst(request));
} else if (url.pathname.startsWith("/api/")) {
event.respondWith(staleWhileRevalidate(request, event));
} else {
event.respondWith(cacheFirst(request));
}
});
async function networkFirst(request) {
const cache = await caches.open(RUNTIME_CACHE);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
return (await cache.match(request)) || (await caches.match(OFFLINE_URL));
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, response.clone());
}
return response;
}
async function staleWhileRevalidate(request, event) {
const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(request);
const network = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => undefined);
if (cached) {
event.waitUntil(network); // keep the worker alive until the refresh finishes
return cached;
}
return (await network) || new Response(JSON.stringify({ offline: true }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}Two things worth pointing out. First, response.clone() is required because a response body can only be read once: one copy goes to the cache, one to the page. Second, event.waitUntil(network) in stale-while-revalidate tells the browser not to kill the worker before the background refresh is stored.
Should you use Workbox instead?
For a small site, the file above is enough and you understand every line. For a bigger app with a bundler, Workbox (version 7.x at the time of writing) gives you the same strategies as tested modules, plus precache manifests generated from your build output, cache expiration and background sync. If your framework has a PWA plugin, it is almost always Workbox underneath. The concepts are identical, so learning the raw API first makes Workbox config much easier to read.
Showing a custom install prompt
Chromium browsers fire beforeinstallprompt once the install criteria are met. You can stash the event and show your own button at a better moment, such as after a user finishes a game or follows a team.
// install.ts
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<{ outcome: "accepted" | "dismissed" }>;
};
let deferred: BeforeInstallPromptEvent | null = null;
const button = document.querySelector<HTMLButtonElement>("#install")!;
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferred = e as BeforeInstallPromptEvent;
button.hidden = false;
});
button.addEventListener("click", async () => {
if (!deferred) return;
const { outcome } = await deferred.prompt();
console.log("Install outcome:", outcome);
deferred = null; // the event can only be used once
button.hidden = true;
});
window.addEventListener("appinstalled", () => {
button.hidden = true;
});TypeScript's DOM types do not include BeforeInstallPromptEvent, which is why it is typed by hand. As MDN notes, this event is not Baseline: it works in Chrome and Edge, not in Safari or Firefox. Treat it as a progressive enhancement and always have a fallback message.
To detect that the app is already running installed, check the display mode:
const isStandalone =
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari exposes this non-standard flag
(navigator as Navigator & { standalone?: boolean }).standalone === true;PWA iOS support: the caveats
iOS is where most PWA surprises come from, so plan for these up front.
- No install event. Users install from the Share menu with "Add to Home Screen". If you want iOS installs, show a short instruction banner for Safari users who are not already in standalone mode.
- Safari 26 changed the default. With iOS and iPadOS 26, every site added to the Home Screen opens as a web app by default, even without a manifest. Users can switch "Open as Web App" off. Your manifest still controls name, icons and display mode, so keep it complete.
- Storage can be evicted. WebKit can clear script-writable storage (Cache API, IndexedDB, localStorage) for sites the user has not visited recently in Safari. Home Screen web apps are treated more generously. Call
navigator.storage.persist()where supported, and never treat the cache as the only copy of user data. - Web push needs installation. Push notifications on iOS (since iOS 16.4) only work for web apps added to the Home Screen, and permission must be requested from a user gesture.
- Test on a real device. The iOS simulator is fine for layout, but service worker and storage behaviour is best verified on hardware.
Common mistakes with offline-first web apps
These are the bugs I see most often in code reviews:
- Forgetting to version caches. If
SHELL_CACHEnever changes, users keep old files forever. Bump the version on every deploy, or let your build tool generate hashed precache entries. - Caching opaque or error responses. A cached 500 page served as "offline content" is worse than nothing. Check
response.okbeforecache.put. - Precaching too much. Precache the shell only. Pulling 40MB of images on first visit hurts the very users you are trying to help.
- Caching authenticated API responses. On a shared device, the next user could see cached private data. Skip caching for anything user-specific, or clear caches on logout.
- Ignoring update UX. A new worker waits until all tabs close unless you call
skipWaiting(). If you skip waiting, a page may now run old JavaScript against a new worker. For apps with long sessions, I prefer showing a "New version available, reload" toast instead. - Testing only online. Use DevTools, Application panel, and tick "Offline", then also try a slow 3G profile. Flaky networks break more apps than no network.
If you are building this inside a React single-page app, remember that offline support does not fix crawlability. Search engines still need real HTML, which I cover in why your React app is not ranking. And because a service worker makes repeat visits faster but not the first one, it pairs well with the work in Core Web Vitals for React.
FAQ
What do I need to make a website a PWA?
You need HTTPS, a web app manifest with a name, start_url, display mode and 192px and 512px icons, and in practice a service worker so the app keeps working offline. Chromium browsers also apply a small engagement check before offering installation.
Do PWAs work offline on iOS?
Yes. Safari supports service workers and the Cache API, so an iOS PWA can work offline. The caveats are installation (there is no beforeinstallprompt event, users install from the Share menu) and storage, which Safari can evict for sites that are not added to the Home Screen.
Which service worker caching strategy should I use?
Use cache-first for versioned static assets, network-first for HTML navigations with an offline fallback, and stale-while-revalidate for data that can be slightly old. Most real apps mix all three by request type.
Why is my service worker not updating?
A new service worker installs but waits until every tab using the old one is closed. Change the cache version, call skipWaiting and clients.claim if you want it to activate immediately, and make sure your server does not cache sw.js itself for long.
Wrapping up
Offline support is not a framework feature, it is a manifest, one service worker file and a clear decision about what each request should do when the network fails. Start with the app shell and an offline page, add stale-while-revalidate for your data, and test on a real phone with the network turned off. Once that works, the install prompt is the easy part.
- #pwa
- #service-worker
- #offline
- #caching
- #javascript
Keep reading
React7 min read
TanStack Query Guide: React Data Fetching Done Right
A practical TanStack Query (React Query v5) guide: query keys, caching with staleTime and gcTime, mutations, optimistic updates, pagination and infinite scroll.
Read articleNext.js8 min read
Next.js Caching Explained: use cache, cacheLife, ISR
Next.js caching explained for Next.js 16: 'use cache', cacheLife, cacheTag, revalidateTag vs updateTag vs revalidatePath, ISR and fetch caching.
Read articleJavaScript8 min read
JavaScript Event Loop Explained: Microtasks vs Macrotasks
JavaScript event loop explained with clear examples: the call stack, task queue, microtasks vs macrotasks, promises, async/await and how rendering fits in.
Read article