Progressive Web Apps: Building Offline-First Experiences
Learn how to build progressive web apps with offline-first architecture, service workers, caching strategies, push notifications, and installability for native-like web experiences.
Progressive Web Apps represent one of the most impactful advancements in web development. They bridge the gap between web and native applications, delivering installability, offline functionality, and push notifications without requiring users to visit an app store. For businesses, PWAs reduce development costs by maintaining a single codebase while delivering an experience that rivals native apps on performance and engagement.
This guide covers the essential techniques for building a production-quality PWA with genuine offline-first capabilities.
Understanding the PWA Foundation
A Progressive Web App is built on three technical pillars: a secure origin (HTTPS), a Web App Manifest, and a Service Worker. Together, these enable the app to be installed on a user's device, work without a network connection, and deliver push notifications.
The Web App Manifest tells the browser how your app should appear when installed:
{
"name": "Acme Project Manager",
"short_name": "Acme PM",
"description": "Manage your projects and tasks efficiently",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#2563eb",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}Link the manifest in your HTML head and add the necessary meta tags for a polished install experience:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />Building a Service Worker
The Service Worker is the engine of a PWA. It is a JavaScript file that runs in a separate thread, intercepting network requests and enabling offline functionality. While libraries like Workbox simplify Service Worker development, understanding the fundamentals is essential.
Register the Service Worker from your main application:
// lib/register-sw.ts
export async function registerServiceWorker() {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener("statechange", () => {
if (
newWorker.state === "activated" &&
navigator.serviceWorker.controller
) {
// New version available - prompt user to refresh
showUpdateNotification();
}
});
}
});
} catch (error) {
console.error("Service Worker registration failed:", error);
}
}
}The Service Worker itself handles installation, activation, and fetch events:
// public/sw.js
const CACHE_NAME = "app-cache-v1";
const STATIC_ASSETS = [
"/",
"/dashboard",
"/offline",
"/styles/main.css",
"/scripts/app.js",
"/icons/icon-192.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
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();
});Implementing Caching Strategies
Different resources require different caching approaches. The four primary strategies are cache-first, network-first, stale-while-revalidate, and network-only.
// Cache-first: for static assets that rarely change
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/assets/")) {
event.respondWith(
caches.match(event.request).then(
(cached) => cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
)
);
return;
}
// Network-first: for API calls where freshness matters
if (url.pathname.startsWith("/api/")) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Stale-while-revalidate: for pages
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
return cached || fetchPromise;
})
);
});For production applications, use Google's Workbox library which provides these strategies as composable modules with much less boilerplate:
import { registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "images",
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new NetworkFirst({
cacheName: "api-responses",
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }),
],
})
);Handling Offline Data Synchronization
True offline-first applications need to handle data that users create or modify while offline. The Background Sync API allows you to queue failed requests and retry them when connectivity is restored.
// In your application code
async function submitForm(data) {
try {
await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} catch {
// Network failed - store for later sync
const registration = await navigator.serviceWorker.ready;
await saveToIndexedDB("pending-submissions", data);
await registration.sync.register("sync-submissions");
}
}
// In your Service Worker
self.addEventListener("sync", (event) => {
if (event.tag === "sync-submissions") {
event.waitUntil(syncPendingSubmissions());
}
});
async function syncPendingSubmissions() {
const pending = await getFromIndexedDB("pending-submissions");
for (const submission of pending) {
try {
await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submission),
});
await removeFromIndexedDB("pending-submissions", submission.id);
} catch {
// Still offline - sync will retry
return;
}
}
}For complex offline data requirements, consider libraries like Dexie.js for IndexedDB management or RxDB for real-time reactive offline-first databases.
Implementing Push Notifications
Push notifications re-engage users and are a key differentiator between PWAs and regular websites. The implementation involves requesting permission, subscribing to a push service, and handling incoming messages.
// lib/push-notifications.ts
export async function subscribeToPush(): Promise<PushSubscription | null> {
const permission = await Notification.requestPermission();
if (permission !== "granted") return null;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_KEY!),
});
// Send subscription to your server
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
return subscription;
}Handle incoming push messages in the Service Worker:
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title || "New Update", {
body: data.body,
icon: "/icons/icon-192.png",
badge: "/icons/badge-72.png",
data: { url: data.url || "/" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data.url;
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
const existing = windowClients.find((c) => c.url === url);
if (existing) return existing.focus();
return clients.openWindow(url);
})
);
});Always request notification permission contextually, explaining why notifications would be valuable, rather than prompting immediately on page load. Aggressive permission requests lead to high denial rates.
Testing and Auditing Your PWA
Use Lighthouse in Chrome DevTools to audit your PWA against Google's checklist. A fully compliant PWA should score 100 on the PWA audit, covering installability, offline support, and performance.
Test offline behavior by using Chrome DevTools' Network tab to simulate offline mode. Verify that your app loads its shell, displays cached content, and queues user actions for sync. Test on real devices with airplane mode enabled to catch issues that simulators miss.
Monitor Service Worker behavior in production using the Application tab in DevTools and by logging cache hit rates and sync success rates in your analytics platform.