Home/Blog

How to Build an Offline-First Progressive Web App

Build an offline-first PWA that holds up in production: manifest and install, service worker updates, caching strategies, offline sync, push, iOS limits.

How to Build an Offline-First Progressive Web App

A progressive web app is a website that browsers can install and that keeps working on a bad or missing connection. It needs three things: HTTPS, a web app manifest that describes how the installed app looks and launches, and a service worker that intercepts requests so the app shell and data can come from a local cache. The hard parts are not the setup. They are choosing the right caching strategy per request type, shipping service worker updates without stranding users on old code, syncing offline writes safely, and working within what Safari on iOS does and does not support.

Key takeaways

  • Pick a caching strategy per request type: cache-first for versioned assets, network-first for HTML and API reads, stale-while-revalidate for content that can be a little old.
  • Never call skipWaiting() blindly. Prompt the user, then activate the new worker and reload, so old pages never run against new assets.
  • Queue offline writes in IndexedDB with an idempotency key, and retry on reconnect. Background Sync is a Chromium-only bonus, not the mechanism.
  • iOS supports Home Screen install and, since iOS 16.4, web push for installed apps. It has no install prompt and no Background Sync.
  • Ship a kill-switch service worker before you need one. A broken worker can pin users to a broken app until you replace it.

When a PWA is the right choice

NeedPWANative or cross-platform
Forms, content, dashboards, internal toolsStrong fitOverkill
One codebase, instant updates, no store reviewStrong fitStore review on every release
Offline reading and offline data entryGood, with careGood
Push notificationsGood on Android and desktop; iOS only for Home Screen appsFull support
Reliable background work on iOSWeakStrong
Bluetooth, NFC, deep OS integrationPatchy, Chromium-only for many APIsStrong
App store discoveryLimitedStrong

If the product sits in the top half of that table, a PWA is the cheaper path to an installable app. If it lives in the bottom half, see our React Native guide for the cross-platform route.

The manifest and installation

The manifest tells the browser the app's name, icons, start URL and display mode:

{
  "id": "/",
  "name": "Cadence Project Manager",
  "short_name": "Cadence",
  "description": "Plan projects and track tasks, online or off.",
  "start_url": "/dashboard?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#09090b",
  "theme_color": "#2563eb",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    { "src": "/screenshots/board-narrow.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" },
    { "src": "/screenshots/board-wide.png", "sizes": "1920x1080", "type": "image/png", "form_factor": "wide" }
  ]
}

Details that matter:

  • id fixes the app's identity. Without it, changing start_url later can make browsers treat the app as a different one.
  • Separate maskable icons. A maskable icon has padding so the OS can crop it into a circle or squircle. Marking one file as both any and maskable makes it look wrong in one of the two contexts.
  • The query string on start_url lets analytics separate installed launches from browser visits.
  • Screenshots enable the richer install dialog in Chromium browsers.

Add <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> for the iOS Home Screen icon. In Next.js, an app/manifest.ts file that returns a MetadataRoute.Manifest object generates the manifest and the link tag for you.

Installation differs by platform. Chromium browsers (Chrome, Edge, Samsung Internet) fire beforeinstallprompt, which you can capture to show your own install button at a sensible moment. Safari on iOS has no prompt: users install from the Share menu with Add to Home Screen, so if installation matters, show iOS users a short instruction instead of a button that does nothing.

let deferredPrompt: BeforeInstallPromptEvent | null = null;
 
window.addEventListener("beforeinstallprompt", (event) => {
  event.preventDefault();
  deferredPrompt = event as BeforeInstallPromptEvent;
  showInstallButton();
});
 
export async function install() {
  if (!deferredPrompt) return;
  await deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  track("pwa_install_prompt", { outcome });
  deferredPrompt = null;
}

BeforeInstallPromptEvent is not in TypeScript's standard DOM types, so declare it yourself.

Caching strategies, per request type

The service worker sits between the page and the network. For each request it decides where the response comes from. One strategy for everything is the most common PWA mistake.

RequestStrategyWhy
Hashed JS, CSS, fonts (app.3f9a1c.js)Cache-first, precached at installThe URL changes when the content does, so a cached copy is never stale
HTML navigationsNetwork-first with a timeout, fall back to cache, then an offline pageUsers get fresh HTML online and a working shell offline
API reads the user needs offlineNetwork-first, fall back to cacheFresh when possible, last known data when not
Avatars, thumbnails, articlesStale-while-revalidateInstant from cache, refreshed in the background
Writes (POST, PUT, DELETE), auth, paymentsNetwork-only, with an offline queue for writesNever serve these from cache

Workbox implements all of these. A service worker built with it:

import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, NavigationRoute, setCatchHandler } from "workbox-routing";
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import * as navigationPreload from "workbox-navigation-preload";
 
// Hashed build assets, injected by the build tool.
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
 
// Start the network request for navigations while the worker boots.
navigationPreload.enable();
 
registerRoute(
  new NavigationRoute(
    new NetworkFirst({
      cacheName: "pages",
      networkTimeoutSeconds: 3,
      plugins: [new CacheableResponsePlugin({ statuses: [200] })],
    }),
  ),
);
 
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/projects"),
  new NetworkFirst({
    cacheName: "api-projects",
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 7 * 24 * 60 * 60 }),
    ],
  }),
);
 
registerRoute(
  ({ request }) => request.destination === "image",
  new StaleWhileRevalidate({
    cacheName: "images",
    plugins: [new ExpirationPlugin({ maxEntries: 300, purgeOnQuotaError: true })],
  }),
);
 
// Offline fallback for navigations that miss both network and cache.
setCatchHandler(async ({ request }) => {
  if (request.destination === "document") {
    return (await caches.match("/offline")) ?? Response.error();
  }
  return Response.error();
});

The /offline page must itself be precached. CacheableResponsePlugin with status 200 keeps error pages and redirects out of the cache. For Next.js apps, Serwist (a maintained fork of Workbox with a Next.js integration) wires precaching into the build. Precache the shell, not every route: precaching dozens of pages makes every install and update download all of them.

Updating the service worker safely

A new service worker installs in the background and then waits until every tab controlled by the old one is closed. That waiting is a feature. If the new worker takes over immediately with skipWaiting(), an open page still running old JavaScript can request a code-split chunk that the new worker's cache no longer has, and the app breaks mid-session.

Prompt instead:

// In the page
export async function registerServiceWorker() {
  if (!("serviceWorker" in navigator)) return;
  const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
 
  const offerUpdate = (worker: ServiceWorker) => {
    showUpdateBanner(() => worker.postMessage({ type: "SKIP_WAITING" }));
  };
 
  if (reg.waiting) offerUpdate(reg.waiting);
  reg.addEventListener("updatefound", () => {
    const next = reg.installing;
    next?.addEventListener("statechange", () => {
      if (next.state === "installed" && navigator.serviceWorker.controller) offerUpdate(next);
    });
  });
 
  let reloading = false;
  navigator.serviceWorker.addEventListener("controllerchange", () => {
    if (reloading) return;
    reloading = true;
    window.location.reload();
  });
}
// In the service worker
self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});

Serve sw.js with Cache-Control: no-cache and never put a hash in its file name. The browser compares the worker script byte for byte to decide whether there is an update; a CDN that caches sw.js for a day delays every fix by a day.

Offline writes: an outbox, not a hope

Reading offline is caching. Writing offline is a distributed systems problem: the user creates or edits data on a device that cannot reach the server, and you must apply it exactly once later, possibly after someone else changed the same record.

The pattern that holds up is an outbox in IndexedDB:

import { openDB } from "idb";
 
const dbPromise = openDB("cadence", 1, {
  upgrade(db) {
    db.createObjectStore("outbox", { keyPath: "id" });
  },
});
 
export async function saveTask(task: TaskInput) {
  const op = {
    id: crypto.randomUUID(), // doubles as the idempotency key
    url: "/api/tasks",
    method: "POST",
    body: task,
    baseVersion: task.version ?? null,
    createdAt: Date.now(),
  };
  const db = await dbPromise;
  await db.put("outbox", op);
  applyOptimistically(task);
  void flushOutbox();
}
 
export async function flushOutbox() {
  const db = await dbPromise;
  for (const op of await db.getAll("outbox")) {
    let res: Response;
    try {
      res = await fetch(op.url, {
        method: op.method,
        headers: { "Content-Type": "application/json", "Idempotency-Key": op.id },
        body: JSON.stringify(op.body),
      });
    } catch {
      return; // still offline, try again later
    }
    if (res.ok) {
      await db.delete("outbox", op.id);
    } else if (res.status === 409) {
      await db.delete("outbox", op.id);
      surfaceConflict(op, await res.json());
    } else if (res.status >= 500) {
      return; // server trouble, keep the operation and retry later
    } else {
      await db.delete("outbox", op.id);
      reportRejected(op, res.status);
    }
  }
}
 
window.addEventListener("online", () => void flushOutbox());
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") void flushOutbox();
});

Three decisions make this safe:

  • Idempotency keys. A request can succeed on the server and fail on the way back. The retry must not create a second task. The server stores the key and replays the original response.
  • Conflict detection. Send the version the edit was based on. If the server's version has moved, return 409 and let the user choose, rather than silently overwriting a colleague's change. Last-write-wins is acceptable only for data where losing an edit does not matter.
  • Flush triggers you control. The Background Sync API (registration.sync.register()) can run the flush from the service worker after the tab closes, but only in Chromium browsers. Use it as an extra trigger, and keep the online, visibility and app-start triggers as the mechanism that works everywhere.

For heavier offline data needs (large datasets, queries, live sync), a local database layer such as Dexie or RxDB saves you from writing IndexedDB plumbing by hand. Our guide to real-time apps with WebSockets and SSE covers the server side of keeping clients in sync once they reconnect.

Push notifications

Web push needs a VAPID key pair, a subscription per device, and a server that sends to each subscription's endpoint:

export async function subscribeToPush() {
  const permission = await Notification.requestPermission();
  if (permission !== "granted") return null;
 
  const reg = await navigator.serviceWorker.ready;
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
  });
 
  await fetch("/api/push/subscriptions", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(subscription),
  });
  return subscription;
}
// In the service worker
self.addEventListener("push", (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? "Cadence", {
      body: data.body,
      icon: "/icons/icon-192.png",
      tag: data.tag,
      data: { url: data.url ?? "/" },
    }),
  );
});
 
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const url = new URL(event.notification.data.url, self.location.origin).href;
  event.waitUntil(
    self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => {
      const open = windows.find((w) => w.url === url);
      return open ? open.focus() : self.clients.openWindow(url);
    }),
  );
});

Rules that keep push usable:

  • Ask in context. Call requestPermission() after a user action that makes the value obvious ("Notify me when this build finishes"), never on page load. A denied permission is very hard to win back.
  • Always show a notification. userVisibleOnly: true is a promise; browsers can revoke push for workers that receive messages without showing anything.
  • Prune dead subscriptions. When the push service answers 404 or 410, delete that subscription. Otherwise your send queue fills with endpoints that will never work.
  • On iOS, push works only for Home Screen apps (iOS 16.4 and later), and permission must be requested from a user gesture inside the installed app.

Platform support you can rely on

CapabilityChromium (Chrome, Edge, Android)Safari on iOS and iPadOS
InstallAutomatic prompt via beforeinstallpromptManual, Share menu, Add to Home Screen
Service worker offline cachingYesYes
Web pushYesYes for Home Screen apps, iOS 16.4 and later
Background SyncYesNo
Periodic Background SyncInstalled apps onlyNo

Check current support on MDN or caniuse before you depend on any API beyond this list. Design so that every capability is an enhancement: the app must still work, with fewer conveniences, where an API is missing.

Testing a PWA

  • DevTools Application panel. Inspect the manifest, the service worker's state, cache contents and storage usage. "Update on reload" helps during development; turn it off before you test update flows, because it hides them.
  • Automated offline tests. Playwright can take a browser context offline and verify that the shell loads and queued writes flush on reconnect:
import { test, expect } from "@playwright/test";
 
test("creates a task offline and syncs it on reconnect", async ({ page, context }) => {
  await page.goto("/dashboard");
  await page.evaluate(() => navigator.serviceWorker.ready);
 
  await context.setOffline(true);
  await page.reload();
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
 
  await page.getByRole("button", { name: "New task" }).click();
  await page.getByLabel("Title").fill("Write release notes");
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByText("Waiting to sync")).toBeVisible();
 
  await context.setOffline(false);
  await expect(page.getByText("Waiting to sync")).toBeHidden();
});
  • Real devices. Airplane mode on a real phone, and a slow, flaky connection rather than a clean offline switch, finds bugs that emulation hides.
  • Phone-width layout. Installed PWAs are used almost entirely on phones, where a single overflowing element makes the whole app scroll sideways. wontfit checks a running app at phone and tablet widths, names the element that overflows, and can fail CI when a layout breaks.

Lighthouse no longer has a PWA category (it was removed in Lighthouse 12), so do not look for a PWA score. Installability shows in the DevTools manifest pane, and offline behavior needs tests like the one above. Our testing strategies guide covers where these end-to-end checks fit in the wider suite.

What goes wrong in production

  • A broken service worker pins users to a broken app. It keeps serving the cached shell no matter what you deploy. Keep a kill-switch worker ready to deploy at the same URL:
// sw.js kill switch: clears caches, unregisters, reloads open pages.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(keys.map((key) => caches.delete(key)));
      await self.registration.unregister();
      const windows = await self.clients.matchAll({ type: "window" });
      windows.forEach((w) => w.navigate(w.url));
    })(),
  );
});
  • Private data survives logout. API responses cached for offline use stay in Cache Storage after the user signs out, on what may be a shared device. Delete user-specific caches and IndexedDB stores on logout.
  • Caching error pages. A network-first route caches a 500 or a login redirect and serves it offline for days. Cache only successful responses.
  • Quota surprises. Storage is best effort by default and can be evicted. Cross-origin opaque responses count against quota at a padded size, so avoid caching them. Request persistence with navigator.storage.persist() for apps where offline data matters, and handle quota errors.
  • Old shells calling new APIs. A user who has not reloaded in a week runs last week's JavaScript against this week's API. Keep APIs backward compatible for at least one release window, or have the shell check a minimum version and force an update.
  • Offline writes that never sync. The outbox silently grows because an operation is rejected with a 400 forever. Surface stuck operations to the user and report them to your error tracker.

Frequently Asked Questions

What makes a web app a progressive web app?

A progressive web app is a website served over HTTPS with a web app manifest, so browsers can install it, and usually a service worker, so it can work offline and receive push messages. There is no single certification. In practice it means the app installs, launches like an app, and stays useful on a bad or missing connection.

Do PWAs work on iPhone?

Yes, with limits. Safari on iOS lets users add a web app to the Home Screen from the Share menu, and since iOS 16.4 those Home Screen apps can receive web push notifications. There is no automatic install prompt, and Background Sync is not supported, so offline writes must be retried when the app is next opened or comes back online.

Should I build a PWA or a native app?

Build a PWA when your product is mostly forms, content and dashboards, when you want one codebase and instant updates, and when app store discovery is not essential. Choose native or a cross-platform framework when you need deep device integration, reliable background processing on iOS, or store presence. Many teams ship a PWA first and add native apps once usage justifies them.

How do I update a service worker without breaking users?

Let the new worker install and wait, tell the user an update is ready, and activate it only when they accept, then reload the page. Calling skipWaiting immediately can mix old page code with new cached assets. Serve the service worker file with no-cache headers so CDNs and browsers always check for a new version.

How much data can a PWA store offline?

Browsers give each origin a quota based on available disk space, and the exact limits differ by browser and change over time. Storage is best effort by default and can be evicted under pressure, so call navigator.storage.persist to request persistent storage and check navigator.storage.estimate before large downloads. Treat anything stored offline as a cache that can disappear.

Need help building an offline-first app?

We build web apps that have to work on bad connections, and we stay on the pager for them after launch, which is why the update path and the kill switch get built first. If you are planning a PWA, fighting a service worker that will not let go, or designing offline sync that cannot lose data, talk to the senior engineer who would do the work.