Service workers let your app work offline and load faster with smart caching. Here is how to implement them properly.
Register the Service Worker
// lib/register-sw.ts
export function registerServiceWorker() {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (
newWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
// New version ready β notify user
dispatchEvent(new CustomEvent("sw-update-available"));
}
});
});
} catch (error) {
console.error("SW registration failed:", error);
}
});
}
Service Worker With Cache Strategies
// public/sw.js
const CACHE_NAME = "app-cache-v1";
const STATIC_ASSETS = [
"/offline",
"/favicon.ico",
];
// Install: pre-cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
);
self.skipWaiting();
});
// Activate: clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
),
);
self.clients.claim();
});
// Fetch: apply strategy per request type
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET and external requests
if (request.method !== "GET" || url.origin !== self.location.origin) return;
// API calls: network first, no cache fallback
if (url.pathname.startsWith("/api/")) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache first
if (
url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|woff2?)$/) ||
url.pathname.startsWith("/_next/static/")
) {
event.respondWith(cacheFirst(request));
return;
}
// Pages: stale-while-revalidate
event.respondWith(staleWhileRevalidate(request));
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response("Offline", { status: 503 });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
return cached ?? new Response(JSON.stringify({ error: "Offline" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
if (cached) {
// Return cached, update in background
fetchPromise; // fire and forget
return cached;
}
// No cache: wait for network
const response = await fetchPromise;
if (response) return response;
// Offline fallback page
return caches.match("/offline") ?? new Response("Offline", { status: 503 });
}
Background Sync for Forms
// In sw.js β add background sync for form submissions
self.addEventListener("sync", (event) => {
if (event.tag === "form-sync") {
event.waitUntil(syncPendingForms());
}
});
async function syncPendingForms() {
const db = await openDB();
const tx = db.transaction("pending-forms", "readonly");
const store = tx.objectStore("pending-forms");
const entries = await store.getAll();
for (const entry of entries) {
try {
const response = await fetch(entry.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry.data),
});
if (response.ok) {
const deleteTx = db.transaction("pending-forms", "readwrite");
deleteTx.objectStore("pending-forms").delete(entry.id);
}
} catch {
// Will retry on next sync
}
}
}
Offline Hook
"use client";
import { useSyncExternalStore } from "react";
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true;
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
Offline Banner Component
"use client";
import { useOnlineStatus } from "@/hooks/use-online-status";
export function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div
role="alert"
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 bg-amber-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm font-medium"
>
You are offline. Some features may be unavailable.
</div>
);
}
Offline Fallback Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold mb-4">You are offline</h1>
<p className="text-muted-foreground mb-6">
Check your internet connection and try again. Previously visited pages
may still be available.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg"
>
Try Again
</button>
</div>
</div>
);
}
Need Offline-Capable Apps?
We build progressive web apps that work reliably offline. Contact us to discuss your project.