# How PWA Offline Support and Service Worker Work in Pi Web

> Discover how Pi Web leverages PWA offline support and service workers to precache resources, serve fallback pages, and ensure fast loading with cache-first strategies.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: deep-dive
- Published: 2026-08-15

---

**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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/public/sw.js) (lines 8–14) defines what gets cached:

- [`offline.html`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/offline.html) page.

This logic appears in lines 47–55 of [`public/sw.js`](https://github.com/agegr/pi-web/blob/main/public/sw.js):

```javascript
// 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

```javascript
// 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`](https://github.com/agegr/pi-web/blob/main/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

```tsx
// 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`](https://github.com/agegr/pi-web/blob/main/components/PwaRegistration.tsx) |
| **Installation** | `install` event | Opens `pi-web-static-<version>` cache, adds `PRECACHE_URLS` | [`public/sw.js`](https://github.com/agegr/pi-web/blob/main/public/sw.js) |
| **Activation** | `activate` event | Deletes old `pi-web-*` caches, claims clients | [`public/sw.js`](https://github.com/agegr/pi-web/blob/main/public/sw.js) |
| **Navigation** | `fetch` event | Network first, fallback to [`offline.html`](https://github.com/agegr/pi-web/blob/main/offline.html) | [`public/sw.js`](https://github.com/agegr/pi-web/blob/main/public/sw.js) lines 47–55 |
| **Static asset** | `fetch` event | Cache first, populate cache on miss | [`public/sw.js`](https://github.com/agegr/pi-web/blob/main/public/sw.js) `cacheFirst()` |

## Customizing the Caching Behavior

Adding a new asset to precaching requires editing `PRECACHE_URLS` in [`public/sw.js`](https://github.com/agegr/pi-web/blob/main/public/sw.js):

```javascript
// 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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.