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:
- Check the cache first — return immediately if found
- If missing, fetch from network
- Store successful responses in cache (simple, same-origin requests only)
- 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.htmlpage - Cache-first static assets —
/_next/static/files serve instantly from cache, with background refresh - Versioned updates —
NEXT_PUBLIC_APP_VERSIONcontrols cache invalidation and service worker updates - Production-only registration — The
PwaRegistrationcomponent 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →