How PWA Offline Support and Service Worker Work in Pi Web

Pi Web implements Progressive Web App (PWA) offline support through a custom service worker that precaches critical resources, serves a fallback page for failed navigations, and uses cache-first strategies for static assets.

The agegr/pi-web repository demonstrates a lightweight, production-ready PWA architecture built on Next.js. Rather than relying on external libraries like Workbox, the project uses a hand-written service worker in public/sw.js paired with a React registration component. This approach gives full control over caching behavior while keeping the implementation transparent and easy to modify.

Service Worker Responsibilities

The service worker handles three core tasks to enable offline functionality. Each task maps to specific lifecycle events and fetch interception logic.

Precaching Core Resources on Install

When the service worker installs, it opens a versioned cache named pi-web-static-<version> and populates it with essential files. This guarantees the UI remains functional even during complete network outages.

The PRECACHE_URLS array in public/sw.js (lines 8–14) defines what gets cached:

  • offline.html — the fallback page shown when navigation fails
  • /manifest.webmanifest — the PWA manifest for installability
  • Icon assets in /icons/ — app icons for home screen and taskbar

The version suffix in the cache name comes from NEXT_PUBLIC_APP_VERSION. Changing this environment variable forces cache-busting and triggers a fresh install.

Serving an Offline Fallback for Navigation Requests

Navigation requests (request.mode === "navigate") receive special treatment. The service worker attempts a network fetch first, but if that fails, it immediately serves the cached offline.html page.

This logic appears in lines 47–55 of public/sw.js:

// Excerpt from public/sw.js fetch event handler
if (request.mode === "navigate") {
  try {
    const networkResponse = await fetch(request);
    return networkResponse;
  } catch (error) {
    const cachedOffline = await caches.match(OFFLINE_URL);
    return cachedOffline || new Response("Offline page not found", { status: 404 });
  }
}

Users see a friendly "You appear to be offline" message rather than a browser error page.

Cache-First Strategy for Static Assets

All other requests targeting /_next/static/ or precached URLs use the cacheFirst() function. This approach prioritizes speed and offline availability:

  1. Check the cache first — return immediately if found
  2. If missing, fetch from network
  3. Store successful responses in cache (simple, same-origin requests only)
  4. Return the response
// From public/sw.js
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok && response.type === "basic") {
    const cache = await caches.open(STATIC_CACHE);
    await cache.put(request, response.clone());
  }
  return response;
}

This keeps Next.js chunks and other static assets fast after the first load.

Registering the Service Worker

The PwaRegistration component in components/PwaRegistration.tsx handles client-side registration. It follows best practices for timing and browser support:

  • Only runs in production (NODE_ENV === "production")
  • Checks for browser support ("serviceWorker" in navigator)
  • Uses a versioned URL (/sw.js?v=<app-version>) to trigger updates
  • Waits for page load to avoid blocking critical rendering
// components/PwaRegistration.tsx
export function PwaRegistration() {
  useEffect(() => {
    if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) {
      return;
    }

    const register = () => {
      const appVersion = process.env.NEXT_PUBLIC_APP_VERSION ?? "dev";
      const scriptUrl = `/sw.js?v=${encodeURIComponent(appVersion)}`;
      
      navigator.serviceWorker
        .register(scriptUrl, { scope: "/", updateViaCache: "none" })
        .catch((err) => console.error("Failed to register service worker:", err));
    };

    if (document.readyState === "complete") {
      register();
    } else {
      window.addEventListener("load", register, { once: true });
    }
  }, []);

  return null;
}

Setting updateViaCache: "none" ensures the browser always checks for updated service worker scripts rather than using HTTP cache heuristics.

Service Worker Lifecycle

Understanding when each piece of code executes helps debug PWA behavior:

Phase Event Action Location
Registration Component mount navigator.serviceWorker.register() called with versioned URL components/PwaRegistration.tsx
Installation install event Opens pi-web-static-<version> cache, adds PRECACHE_URLS public/sw.js
Activation activate event Deletes old pi-web-* caches, claims clients public/sw.js
Navigation fetch event Network first, fallback to offline.html public/sw.js lines 47–55
Static asset fetch event Cache first, populate cache on miss public/sw.js cacheFirst()

Customizing the Caching Behavior

Adding a new asset to precaching requires editing PRECACHE_URLS in public/sw.js:

// public/sw.js
const PRECACHE_URLS = [
  OFFLINE_URL,
  "/manifest.webmanifest",
  "/icons/icon-192.png",
  "/icons/icon-512.png",
  "/icons/apple-touch-icon.png",
  "/fonts/inter-variable.woff2",  // ← Added custom font
  "/critical-styles.css",          // ← Added critical CSS
];

To modify cache expiration or add runtime caching for API responses, extend the fetch event handler with additional branches before the default cacheFirst() call.

Summary

  • Precaching — Core UI assets are stored during service worker install in a versioned cache
  • Offline fallback — Failed navigation requests show a cached offline.html page
  • Cache-first static assets — /_next/static/ files serve instantly from cache, with background refresh
  • Versioned updates — NEXT_PUBLIC_APP_VERSION controls cache invalidation and service worker updates
  • Production-only registration — The PwaRegistration component prevents development conflicts

Frequently Asked Questions

How does Pi Web force a service worker update?

The PwaRegistration component appends ?v=<app-version> to the service worker URL. When NEXT_PUBLIC_APP_VERSION changes, the browser treats this as a new script and installs the updated worker. Upon activation, old caches are deleted and clients are claimed.

Why doesn't the service worker run in development?

The registration code explicitly checks process.env.NODE_ENV !== "production" before executing. This prevents caching headaches during active development where files change frequently. Service workers require a production build to test properly.

What happens if offline.html itself isn't cached?

The install event in public/sw.js waits for all PRECACHE_URLS to be cached before completing (event.waitUntil()). If any precache fails, the service worker installation aborts and the browser will retry on next load. Once installed, offline.html remains available until explicitly deleted.

Can I cache API responses for offline use?

The current implementation focuses on static assets. To cache API responses, add a new condition in the fetch event handler matching your API routes, then use cacheFirst() or implement a stale-while-revalidate strategy. Ensure you handle cache size limits and sensitive data appropriately.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →