Web Engineering ·

Progressive Web Apps in 2026: Service Workers, Push Notifications, and Offline-First Patterns

A practical guide to building production PWAs with service worker lifecycle management, caching strategies, push notifications, and offline-first IndexedDB patterns in TypeScript.

Progressive Web Apps in 2026: Service Workers, Push Notifications, and Offline-First Patterns

The gap between what PWAs can do and what most developers think they can do has never been wider. Browser vendors spent 2023-2025 shipping capabilities that were previously native-only: persistent storage, background fetch, badging, file system access, and more reliable push delivery. The platform is genuinely capable now. The problems that remain are architectural, not browser-compatibility problems.

This guide covers the full stack of modern PWA development: service worker lifecycle, caching strategies, push notifications, and offline-first data management. The code examples are TypeScript throughout and reflect what a production implementation actually looks like, not a hello-world demo.


Service Worker Lifecycle

A service worker is a JavaScript file that runs in a separate thread from your main application. It intercepts network requests, manages caches, and handles push events. Understanding its lifecycle is the prerequisite for everything else.

The three phases are: install, activate, and fetch (plus push and sync, which are event-driven).

Registration

Register from your main application code with scope control:

// src/sw-registration.ts
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
  if (!("serviceWorker" in navigator)) {
    return null;
  }

  try {
    const registration = await navigator.serviceWorker.register("/sw.js", {
      scope: "/",
      updateViaCache: "none", // always fetch new sw.js, never use browser HTTP cache
    });

    registration.addEventListener("updatefound", () => {
      const newWorker = registration.installing;
      if (!newWorker) return;

      newWorker.addEventListener("statechange", () => {
        if (
          newWorker.state === "installed" &&
          navigator.serviceWorker.controller
        ) {
          // a new version is waiting: notify the user
          dispatchEvent(new CustomEvent("sw:update-available"));
        }
      });
    });

    return registration;
  } catch (error) {
    console.error("Service worker registration failed:", error);
    return null;
  }
}

updateViaCache: "none" is important. Without it, the browser may serve a cached copy of your sw.js file and delay updates by up to 24 hours. Setting it to "none" ensures the browser always makes a fresh network request for the service worker script.

Install and Activate

Inside your service worker file, the install event is where you pre-cache assets. The activate event is where you clean up old caches from previous versions.

// sw.ts (compiled and served as /sw.js)
const CACHE_VERSION = "v3";
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const RUNTIME_CACHE = `runtime-${CACHE_VERSION}`;

const PRECACHE_ASSETS = [
  "/",
  "/offline.html",
  "/styles/main.css",
  "/scripts/app.js",
];

self.addEventListener("install", (event: ExtendableEvent) => {
  event.waitUntil(
    caches
      .open(STATIC_CACHE)
      .then((cache) => cache.addAll(PRECACHE_ASSETS))
      .then(() => (self as ServiceWorkerGlobalScope).skipWaiting())
  );
});

self.addEventListener("activate", (event: ExtendableEvent) => {
  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) =>
        Promise.all(
          cacheNames
            .filter(
              (name) => name !== STATIC_CACHE && name !== RUNTIME_CACHE
            )
            .map((name) => caches.delete(name))
        )
      )
      .then(() => (self as ServiceWorkerGlobalScope).clients.claim())
  );
});

skipWaiting() during install causes the new service worker to take control immediately rather than waiting for existing tabs to close. clients.claim() during activate ensures the new worker controls all open tabs without requiring a page reload. Use both together, but understand the tradeoff: if your new service worker has breaking changes in cache structure or API expectations, an open tab could suddenly find itself talking to an incompatible worker.


Caching Strategies

There is no single correct caching strategy. Each route in your application has different freshness requirements.

Cache-First

Serve from cache if available, fall back to network. Use for static assets with versioned filenames (CSS, JS bundles, images).

function cacheFirst(request: Request): Promise<Response> {
  return caches.match(request).then((cached) => {
    if (cached) return cached;

    return fetch(request).then((response) => {
      if (response.ok) {
        const clone = response.clone();
        caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, clone));
      }
      return response;
    });
  });
}

Network-First

Try the network, fall back to cache on failure. Use for API responses where freshness matters but you want offline fallback.

async function networkFirst(
  request: Request,
  fallbackUrl?: string
): Promise<Response> {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(RUNTIME_CACHE);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;

    if (fallbackUrl) {
      const fallback = await caches.match(fallbackUrl);
      if (fallback) return fallback;
    }

    return new Response("Offline", { status: 503 });
  }
}

Stale-While-Revalidate

Serve from cache immediately, then update the cache in the background. Use for content that should be fast but does not need to be perfectly fresh: user profile data, non-critical API responses.

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cached = await caches.match(request);

  const revalidation = fetch(request).then((response) => {
    if (response.ok) {
      caches
        .open(RUNTIME_CACHE)
        .then((cache) => cache.put(request, response.clone()));
    }
    return response;
  });

  // serve cache immediately, revalidate in background
  return cached ?? revalidation;
}

Routing in the Fetch Handler

Wire these strategies to URL patterns in your fetch event handler:

self.addEventListener("fetch", (event: FetchEvent) => {
  const { request } = event;
  const url = new URL(request.url);

  // only handle same-origin requests
  if (url.origin !== self.location.origin) return;

  if (url.pathname.startsWith("/api/")) {
    event.respondWith(networkFirst(request, "/offline.html"));
    return;
  }

  if (url.pathname.match(/\.(js|css|woff2|png|svg)$/)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // HTML navigation: stale-while-revalidate
  if (request.mode === "navigate") {
    event.respondWith(staleWhileRevalidate(request));
    return;
  }
});

Workbox for Production

Rolling your own caching logic is educational but not production-appropriate once you have more than a few route patterns. Workbox handles cache expiration, versioning, broadcast channel updates, and edge cases you will not think of until they bite you.

The key Workbox configuration that maps to the strategies above:

// workbox.config.ts (used with workbox-build or vite-plugin-workbox)
import { defineConfig } from "workbox-build";

export default defineConfig({
  swSrc: "src/sw.ts",
  swDest: "dist/sw.js",
  globDirectory: "dist",
  globPatterns: ["**/*.{js,css,html,woff2,png,svg,ico}"],

  runtimeCaching: [
    {
      urlPattern: /^https:\/\/api\.yourapp\.com\/v1\//,
      handler: "NetworkFirst",
      options: {
        cacheName: "api-responses",
        networkTimeoutSeconds: 3,
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 60 * 60, // 1 hour
        },
        cacheableResponse: { statuses: [0, 200] },
      },
    },
    {
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
      handler: "CacheFirst",
      options: {
        cacheName: "images",
        expiration: {
          maxEntries: 60,
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
        },
      },
    },
    {
      urlPattern: ({ request }) => request.mode === "navigate",
      handler: "StaleWhileRevalidate",
      options: {
        cacheName: "pages",
        expiration: { maxAgeSeconds: 24 * 60 * 60 },
      },
    },
  ],
});

Workbox’s networkTimeoutSeconds is particularly useful for network-first strategies: if the network does not respond within 3 seconds, serve from cache rather than waiting for a slow connection to eventually fail.


Push Notifications

Push notifications in the browser require three components working together: the Push API (service worker side), the Notification API (display), and your server sending messages via a push service.

Requesting Permission and Subscribing

// src/push-notifications.ts
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY;

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, "+")
    .replace(/_/g, "/");
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

export async function subscribeToPush(): Promise<PushSubscription | null> {
  const registration = await navigator.serviceWorker.ready;

  const permission = await Notification.requestPermission();
  if (permission !== "granted") return null;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // send subscription to your server
  await fetch("/api/push/subscribe", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(subscription.toJSON()),
  });

  return subscription;
}

userVisibleOnly: true is not optional. Chrome enforces this: silent push events that do not display a notification will be penalized (the subscription may be revoked after repeated violations). Every push event you receive must result in a visible notification.

Handling Push in the Service Worker

// in sw.ts
interface PushPayload {
  title: string;
  body: string;
  url?: string;
  icon?: string;
  badge?: string;
  tag?: string;
}

self.addEventListener("push", (event: PushEvent) => {
  if (!event.data) return;

  const payload: PushPayload = event.data.json();

  event.waitUntil(
    (self as ServiceWorkerGlobalScope).registration.showNotification(
      payload.title,
      {
        body: payload.body,
        icon: payload.icon ?? "/icons/icon-192.png",
        badge: payload.badge ?? "/icons/badge-72.png",
        tag: payload.tag, // replaces existing notification with same tag
        data: { url: payload.url ?? "/" },
        actions: [
          { action: "view", title: "View" },
          { action: "dismiss", title: "Dismiss" },
        ],
      }
    )
  );
});

self.addEventListener("notificationclick", (event: NotificationClickEvent) => {
  event.notification.close();

  if (event.action === "dismiss") return;

  const url = event.notification.data?.url ?? "/";

  event.waitUntil(
    (self as ServiceWorkerGlobalScope).clients
      .matchAll({ type: "window", includeUncontrolled: true })
      .then((clientList) => {
        for (const client of clientList) {
          if (client.url === url && "focus" in client) {
            return client.focus();
          }
        }
        return (self as ServiceWorkerGlobalScope).clients.openWindow(url);
      })
  );
});

The tag field is important for notification deduplication. If you send five order-status updates with tag: "order-123", the user sees one notification that gets replaced each time. Without tag, they accumulate five notifications.


Offline-First Data with IndexedDB

Caching static assets and API responses handles read-heavy workloads. For applications where users create or modify data while offline, you need a client-side database with a sync queue.

IndexedDB is the right primitive here, but the raw API is painful. Use a wrapper like idb from Jake Archibald.

// src/db.ts
import { openDB, DBSchema, IDBPDatabase } from "idb";

interface SyncQueueItem {
  id: string;
  url: string;
  method: string;
  headers: Record<string, string>;
  body: string;
  timestamp: number;
  retryCount: number;
}

interface AppDB extends DBSchema {
  notes: {
    key: string;
    value: {
      id: string;
      content: string;
      updatedAt: number;
      synced: boolean;
    };
    indexes: { "by-synced": boolean };
  };
  syncQueue: {
    key: string;
    value: SyncQueueItem;
    indexes: { "by-timestamp": number };
  };
}

let db: IDBPDatabase<AppDB> | null = null;

export async function getDB(): Promise<IDBPDatabase<AppDB>> {
  if (db) return db;

  db = await openDB<AppDB>("app-db", 1, {
    upgrade(database) {
      const notes = database.createObjectStore("notes", { keyPath: "id" });
      notes.createIndex("by-synced", "synced");

      const queue = database.createObjectStore("syncQueue", { keyPath: "id" });
      queue.createIndex("by-timestamp", "timestamp");
    },
  });

  return db;
}

export async function saveNote(note: AppDB["notes"]["value"]): Promise<void> {
  const database = await getDB();
  await database.put("notes", { ...note, synced: false });
  await queueSync(`/api/notes/${note.id}`, "PUT", note);
}

async function queueSync(
  url: string,
  method: string,
  body: unknown
): Promise<void> {
  const database = await getDB();
  await database.put("syncQueue", {
    id: crypto.randomUUID(),
    url,
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
    timestamp: Date.now(),
    retryCount: 0,
  });

  // register a background sync if supported
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register("sync-queue");
  }
}

Background Sync

The Background Sync API lets you defer network requests until connectivity is restored, even if the user has closed the tab (within browser-imposed limits).

// in sw.ts
self.addEventListener("sync", (event: SyncEvent) => {
  if (event.tag === "sync-queue") {
    event.waitUntil(processSyncQueue());
  }
});

async function processSyncQueue(): Promise<void> {
  const database = await getDB();
  const items = await database.getAllFromIndex(
    "syncQueue",
    "by-timestamp"
  );

  for (const item of items) {
    try {
      const response = await fetch(item.url, {
        method: item.method,
        headers: item.headers,
        body: item.body,
      });

      if (response.ok) {
        await database.delete("syncQueue", item.id);
        // mark associated record as synced
        // (implementation depends on your data model)
      } else if (response.status >= 400 && response.status < 500) {
        // client error: do not retry, remove from queue
        await database.delete("syncQueue", item.id);
        console.warn(`Sync item ${item.id} rejected with ${response.status}`);
      }
      // 5xx: leave in queue, will retry on next sync event
    } catch {
      // network failure: leave in queue
      if (item.retryCount >= 5) {
        await database.delete("syncQueue", item.id);
        // surface error to user
      } else {
        await database.put("syncQueue", {
          ...item,
          retryCount: item.retryCount + 1,
        });
      }
    }
  }
}

Background Sync is currently supported in Chromium-based browsers. Safari supports a subset via the Periodic Background Sync API but not the one-shot sync shown above. For Safari, the sync queue still works: it just processes when the user next opens the app with connectivity, triggered by the fetch handler or an explicit user action.


PWA vs Native App Tradeoffs

DimensionPWANative
DistributionURL, no app storeApp store required (or sideload)
Installation frictionLow: browser install promptHigher: store listing, review, download
Push notificationsAvailable in Chrome/Edge/Firefox; limited on iOS SafariFull platform support
Hardware accessCamera, mic, geolocation, Bluetooth (partial), NFC (partial)Full access
Background processingLimited to sync and fetch eventsPersistent background tasks
PerformanceNear-native for most UIs; no access to platform-native UI componentsNative rendering, full GPU access
Update deliveryInstant: no store reviewApp store review (iOS), instant (Android)
Offline capabilityFull with service workersFull
DiscoverabilitySearch engines index PWAsApp store search
Storage limitsOrigin-private filesystem, 2-10GB typical quotaEssentially unlimited

The honest assessment: PWAs close 90% of the gap with native for content, productivity, and social applications. Games with 3D rendering, health apps requiring continuous background sensors, and anything needing DRM-protected video still belong on native. For everything else, the reduced distribution friction and single codebase are compelling.

iOS Safari deserves specific mention. Apple shipped Web Push support in Safari 16.4 (2023) and has continued improving it. The remaining gaps on iOS in 2026 are: no persistent background sync, no badging API, and install prompts that are less prominent than on Android. These are real gaps but not blockers for most applications.


Production Considerations

Update strategy. The skipWaiting plus clients.claim pattern is aggressive. For applications with critical in-flight state (shopping carts, multi-step forms), notify the user that an update is available and let them choose when to reload. A BroadcastChannel between the service worker and the main thread is the cleanest mechanism:

// in sw.ts, after install completes
const channel = new BroadcastChannel("sw-updates");
channel.postMessage({ type: "UPDATE_AVAILABLE" });

// in main app
const channel = new BroadcastChannel("sw-updates");
channel.addEventListener("message", (event) => {
  if (event.data.type === "UPDATE_AVAILABLE") {
    showUpdateBanner(); // "New version available. Refresh to update."
  }
});

Cache invalidation. Precached assets should use content-hashed filenames (which your build tool handles). For runtime caches, set explicit expiration via Workbox’s expiration plugin. Without expiration, your runtime cache grows without bound and the browser will eventually evict caches arbitrarily under storage pressure.

Cache versioning. When you change your caching strategy for a route, bump CACHE_VERSION. The activate event cleanup will delete the old caches. Do not try to migrate cache contents between versions: just let them expire and be re-populated.

Debugging service workers. Chrome DevTools is the primary tool. In the Application tab, you can inspect registered workers, view cache contents, simulate offline mode, and force update or unregister workers. The “Bypass for network” checkbox in DevTools disables the service worker for your debugging session without unregistering it. Use this when you need to verify that your server is returning the correct responses.

For production debugging, logging from inside a service worker requires either console.log (visible in DevTools when the worker is inspected) or routing logs through a BroadcastChannel to the main thread where you can send them to your error tracking service.

Storage quota. Use the Storage API to check available quota and request persistent storage:

async function checkStorageQuota(): Promise<void> {
  if (!navigator.storage?.estimate) return;

  const estimate = await navigator.storage.estimate();
  const usedMB = Math.round((estimate.usage ?? 0) / 1024 / 1024);
  const quotaMB = Math.round((estimate.quota ?? 0) / 1024 / 1024);

  if (usedMB / quotaMB > 0.8) {
    // warn user or prune old cache entries
    console.warn(`Storage at ${usedMB}/${quotaMB}MB`);
  }
}

async function requestPersistentStorage(): Promise<boolean> {
  if (!navigator.storage?.persist) return false;
  return navigator.storage.persist();
}

navigator.storage.persist() requests that the browser not evict your origin’s data under storage pressure. Browsers grant this automatically for installed PWAs and may prompt the user for non-installed sites. Call it once after the user has shown clear intent (login, explicit “work offline” action).

Testing offline behavior. Chrome DevTools’ offline simulation only blocks the main thread. Requests from service workers bypass it. To test service worker offline behavior accurately, use the Network Conditions panel (set to “Offline”) rather than the checkbox in the Service Workers panel, or disable the network adapter at the OS level.


The gap between a serviceable PWA and a reliable one comes down to handling the cases that do not happen in your local development environment: cached responses served to a version of the app they were not generated for, sync queues that grow without bounds, push subscriptions that expire silently, and cache storage evicted without warning. None of these are hard to solve, but you have to anticipate them before users encounter them. Build the observability first: log cache hit rates, sync queue depth, push delivery failures, and storage quota utilization. Everything else follows from having the data.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.