Progressive web apps represent a fundamental shift in how we think about the boundary between web and native applications. A PWA is not a framework or a specific technology. It is a set of capabilities -- installability, offline access, push notifications, background sync -- layered on top of a standard web application using open web standards. The user gets an app-like experience. The developer gets the reach and deployment simplicity of the web.
The business case is straightforward. PWAs load instantly on repeat visits, work without a network connection, and can be installed to the home screen without going through an app store. Companies like Pinterest, Starbucks, and Twitter Lite have reported dramatic improvements in engagement and conversion rates after shipping PWAs. Pinterest saw a 60% increase in core engagement metrics. Starbucks doubled daily active users on their web app.
This guide covers the technical foundations you need to build a production-quality PWA: service workers, caching strategies, the web app manifest, push notifications, offline data synchronization, and the tooling that makes it practical.
Service Workers: The Foundation of Every PWA
A service worker is a JavaScript file that runs in a separate thread from your main application. It sits between your web app and the network, intercepting every network request your application makes. This interception capability is what enables offline access, intelligent caching, push notifications, and background sync.
Service workers have a lifecycle: registration, installation, activation, and then fetch handling. Understanding this lifecycle is essential.
Registration
Registration tells the browser where your service worker file lives and what scope it controls:
// main.js or your app's entry point
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
console.log("Service worker registered:", registration.scope);
} catch (error) {
console.error("Service worker registration failed:", error);
}
});
}
The scope parameter determines which requests the service worker can intercept. A service worker registered at /app/sw.js with the default scope can only intercept requests under /app/. Registering at the root (/) gives it control over the entire origin.
Installation and Activation
During installation, you typically precache the static assets your app needs to function offline -- the app shell:
// sw.js
const CACHE_NAME = "app-shell-v1";
const PRECACHE_ASSETS = [
"/",
"/index.html",
"/styles/main.css",
"/scripts/app.js",
"/images/logo.svg",
"/offline.html",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_ASSETS);
})
);
self.skipWaiting(); // Activate immediately without waiting for tabs to close
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim(); // Take control of all open tabs immediately
});
The activation event is where you clean up old caches. When you deploy a new version of your service worker with a new CACHE_NAME, the activate handler deletes the previous version's cache, preventing stale assets from accumulating on the user's device.
Caching Strategies in Practice
The power of service workers comes from the flexibility to implement different caching strategies for different types of resources. There is no single correct strategy -- the right choice depends on the nature of the resource and how fresh it needs to be.
Cache-First (Cache Falling Back to Network)
Best for static assets that change infrequently: CSS, JavaScript bundles, images, fonts. The service worker checks the cache first and only hits the network if the asset is not cached.
self.addEventListener("fetch", (event) => {
if (event.request.destination === "style" ||
event.request.destination === "script" ||
event.request.destination === "image") {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
const responseClone = networkResponse.clone();
caches.open("static-assets-v1").then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
});
})
);
}
});
Network-First (Network Falling Back to Cache)
Best for content that should be fresh when possible but available offline when not: API responses, HTML pages, news feeds. The service worker tries the network first and falls back to the cache if the network is unavailable.
async function networkFirst(request, cacheName, timeout = 3000) {
const cache = await caches.open(cacheName);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const networkResponse = await fetch(request, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
return caches.match("/offline.html");
}
}
The timeout is important. Without it, a slow or hanging network connection degrades the user experience far more than serving a cached response would. Three seconds is a reasonable default for most applications.
Stale-While-Revalidate
Best for resources where showing slightly stale content is acceptable while fetching a fresh version in the background: user avatars, product listings, non-critical API data.
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
});
// Return cached response immediately, update cache in background
return cachedResponse || fetchPromise;
}
This strategy delivers the fastest perceived performance because the user sees content immediately from the cache while the service worker updates the cache in the background. The next visit will show the updated content.
The Web App Manifest
The web app manifest is a JSON file that tells the browser how your PWA should behave when installed. It controls the app's name, icons, theme colors, display mode, and launch behavior.
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A production-quality progressive web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
Link the manifest in your HTML <head>:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
The display property controls how the app appears when launched from the home screen. standalone removes the browser chrome and makes the app look like a native application. Other options include fullscreen (no status bar), minimal-ui (minimal browser controls), and browser (standard browser tab).
Providing both 192x192 and 512x512 icons is the minimum for reliable installation across platforms. The maskable purpose ensures your icon adapts to different icon shapes on Android devices.
Push Notifications
Push notifications let your PWA re-engage users even when the app is not open. The implementation involves three components: the client (requests permission and subscribes), the server (sends push messages), and the service worker (receives and displays notifications).
Client-Side Subscription
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== "granted") {
console.log("Notification permission denied");
return null;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to your server
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
return subscription;
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
Service Worker Push Handler
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {
title: "New notification",
body: "You have a new update.",
};
const options = {
body: data.body,
icon: "/icons/icon-192x192.png",
badge: "/icons/badge-72x72.png",
vibrate: [100, 50, 100],
data: { url: data.url || "/" },
actions: data.actions || [],
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
// Focus existing window or open new one
for (const client of windowClients) {
if (client.url === event.notification.data.url && "focus" in client) {
return client.focus();
}
}
return clients.openWindow(event.notification.data.url);
})
);
});
VAPID (Voluntary Application Server Identification) keys authenticate your server with the push service. Generate them once and store them securely. The server uses the private key to sign push messages; the client uses the public key during subscription.
Offline Data Synchronization
Real offline-first applications need more than cached GET requests. Users expect to create, update, and delete data while offline, with those changes synchronizing when connectivity returns. The Background Sync API handles this.
// In your application code -- queue an action when offline
async function submitForm(data) {
try {
const response = await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return response.json();
} catch (error) {
// Network failed -- store the request for background sync
await saveToOutbox(data);
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-submissions");
return { queued: true };
}
}
async function saveToOutbox(data) {
const db = await openDatabase();
const tx = db.transaction("outbox", "readwrite");
await tx.objectStore("outbox").add({
url: "/api/submissions",
method: "POST",
body: data,
timestamp: Date.now(),
});
}
// In the service worker -- process the outbox when connectivity returns
self.addEventListener("sync", (event) => {
if (event.tag === "sync-submissions") {
event.waitUntil(processOutbox());
}
});
async function processOutbox() {
const db = await openDatabase();
const tx = db.transaction("outbox", "readonly");
const items = await tx.objectStore("outbox").getAll();
for (const item of items) {
try {
await fetch(item.url, {
method: item.method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item.body),
});
// Remove from outbox after successful sync
const deleteTx = db.transaction("outbox", "readwrite");
await deleteTx.objectStore("outbox").delete(item.id);
} catch (error) {
// Will retry on next sync event
break;
}
}
}
IndexedDB is the right storage layer for offline data. It supports structured data, indexes, transactions, and has generous storage limits. Use a wrapper like idb to work with it using promises instead of the raw event-based API.
Tooling: Workbox and Beyond
Writing service workers from scratch is educational but impractical for production applications. Google's Workbox library provides battle-tested implementations of every caching strategy, precaching, background sync, and more.
// sw.js using Workbox
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
} from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
// Precache static assets (injected by build tool)
precacheAndRoute(self.__WB_MANIFEST);
// Cache images with cache-first strategy
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "images",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// Cache API responses with network-first strategy
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new NetworkFirst({
cacheName: "api-responses",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }),
],
})
);
// Cache page navigations with stale-while-revalidate
registerRoute(
({ request }) => request.mode === "navigate",
new StaleWhileRevalidate({
cacheName: "pages",
plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })],
})
);
Workbox integrates with webpack, Rollup, and Vite through plugins that generate the precache manifest automatically during your build process. This eliminates the manual maintenance of asset lists.
For frameworks with built-in PWA support, consider next-pwa for Next.js, vite-plugin-pwa for Vite-based projects, and @angular/pwa for Angular. These handle manifest generation, service worker registration, and build-time precache injection with minimal configuration.
PWA vs. Native: When to Choose Which
A PWA is the right choice when you need broad reach across platforms without maintaining separate codebases, when your application is primarily content-driven or involves standard UI patterns, when you want zero-friction access without app store distribution, and when your update cadence is fast (PWAs update on every visit -- no app store review process).
A native app is the right choice when you need deep hardware access (Bluetooth, NFC, advanced camera features, sensors), when your application requires complex graphics rendering or real-time processing, when you need to appear in app store search results as a primary distribution channel, or when platform-specific UI conventions are critical to the user experience.
Many applications benefit from both. Build the PWA for broad access and engagement, and build native apps for the subset of users who need deeper platform integration.
Building Your PWA
Progressive web apps are not a compromise between web and native. They are a distinct category that combines the strengths of both: the reach and deployability of the web with the reliability and engagement of installed applications. The core technologies -- service workers, the Cache API, the web app manifest, push notifications, and background sync -- are stable, well-supported, and production-ready.
If you are considering a PWA for your product or business, Maranatha Technologies builds progressive web applications that deliver real offline-first experiences and measurable improvements in user engagement. Get in touch to explore how a PWA can serve your users better than a traditional web application.