Building a Progressive Web App from Scratch
Step-by-step guide to building a PWA: web app manifest, service worker registration, caching strategies, offline support, push notifications, and installability.
What you'll learn
- ✓How to create a web app manifest for installability
- ✓How to write a service worker with caching strategies
- ✓How to implement offline support with a fallback page
- ✓How to handle the install prompt and update flow
- ✓How to add push notifications with the Push API
Prerequisites
- •JavaScript and Fetch API basics
- •HTTPS-enabled development environment
The Three Pillars
A PWA has three requirements: a web app manifest, a service worker, and HTTPS. Get those three right and the browser will offer to install your site as an app.
Step 1: The Web App Manifest
{
"name": "TaskFlow - Project Manager",
"short_name": "TaskFlow",
"description": "Lightweight project management in your browser",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#3b82f6",
"orientation": "any",
"scope": "/",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "TaskFlow desktop view"
},
{
"src": "/screenshots/mobile.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "TaskFlow mobile view"
}
],
"shortcuts": [
{
"name": "New Task",
"url": "/tasks/new",
"icons": [{ "src": "/icons/new-task.png", "sizes": "96x96" }]
},
{
"name": "Dashboard",
"url": "/dashboard",
"icons": [{ "src": "/icons/dashboard.png", "sizes": "96x96" }]
}
]
}
Link it in your HTML:
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#3b82f6" />
<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" />
Key fields:
display: "standalone"makes it look like a native app (no browser chrome)scopelimits which URLs the PWA controlspurpose: "maskable"provides icons that work with adaptive icon shapes on Androidscreenshotsenables the richer install dialog on supported browsers
Step 2: The Service Worker
Registration
// src/register-sw.ts
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('SW registered:', registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Every hour
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
The Service Worker File
// public/sw.js
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
// Files to cache immediately on install
const STATIC_ASSETS = [
'/',
'/offline.html',
'/styles/main.css',
'/scripts/app.js',
'/icons/icon-192.png',
];
// Install: cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
console.log('Caching static assets');
return cache.addAll(STATIC_ASSETS);
}),
);
// Activate immediately instead of waiting
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map((key) => {
console.log('Deleting old cache:', key);
return caches.delete(key);
}),
);
}),
);
// Take control of all clients immediately
self.clients.claim();
});
// Fetch: apply caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// API calls: network first, fall back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache first, fall back to network
if (url.pathname.startsWith('/_astro/') || url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages: stale-while-revalidate
event.respondWith(staleWhileRevalidate(request));
});
Caching Strategies
// Cache first: fast, good for static assets
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(STATIC_CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response('Offline', { status: 503 });
}
}
// Network first: fresh data, falls back to cache
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
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' },
});
}
}
// Stale-while-revalidate: serve cache, update in background
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(request, response.clone());
});
}
return response;
})
.catch(() => null);
// Return cached version immediately, or wait for network
if (cached) {
// Fire off background update
fetchPromise;
return cached;
}
// No cache, must wait for network
const response = await fetchPromise;
if (response) return response;
// Complete offline fallback
return caches.match('/offline.html');
}
Step 3: Offline Fallback Page
<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline - TaskFlow</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #0f172a;
color: #e2e8f0;
text-align: center;
}
.container { max-width: 400px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #94a3b8; }
button {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
}
</style>
</head>
<body>
<div class="container">
<h1>You are offline</h1>
<p>Check your internet connection and try again.</p>
<button onclick="window.location.reload()">Retry</button>
</div>
</body>
</html>
Step 4: The Install Prompt
Browsers show an install prompt automatically, but you can capture it and trigger it at a better time:
// src/install-prompt.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;
window.addEventListener('beforeinstallprompt', (event) => {
// Prevent the default browser prompt
event.preventDefault();
deferredPrompt = event as BeforeInstallPromptEvent;
// Show your custom install button
const installButton = document.getElementById('install-button');
if (installButton) {
installButton.style.display = 'block';
installButton.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('Install prompt outcome:', outcome);
deferredPrompt = null;
installButton.style.display = 'none';
});
}
});
// Detect if the app is already installed
window.addEventListener('appinstalled', () => {
console.log('App installed');
deferredPrompt = null;
const installButton = document.getElementById('install-button');
if (installButton) installButton.style.display = 'none';
});
// Check if running in standalone mode (already installed)
if (window.matchMedia('(display-mode: standalone)').matches) {
console.log('Running as installed PWA');
}
Step 5: Service Worker Updates
When you deploy a new version, the browser detects the changed sw.js and triggers an update:
// In your registration code
navigator.serviceWorker.register('/sw.js').then((registration) => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available — prompt the user
showUpdateBanner();
}
});
});
});
function showUpdateBanner() {
const banner = document.createElement('div');
banner.innerHTML = `
<div style="position:fixed;bottom:0;left:0;right:0;padding:1rem;
background:#1e40af;color:white;text-align:center;z-index:9999;">
A new version is available.
<button onclick="window.location.reload()" style="margin-left:1rem;
padding:0.25rem 0.75rem;background:white;color:#1e40af;border:none;
border-radius:0.25rem;cursor:pointer;">Update</button>
</div>
`;
document.body.appendChild(banner);
}
Step 6: Push Notifications
Request Permission
async function requestNotificationPermission(): Promise<boolean> {
if (!('Notification' in window)) return false;
if (Notification.permission === 'granted') return true;
if (Notification.permission === 'denied') return false;
const result = await Notification.requestPermission();
return result === 'granted';
}
Subscribe to Push
async function subscribeToPush(): Promise<PushSubscription | null> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
import.meta.env.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;
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
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)));
}
Handle Push in Service Worker
// In sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {
title: 'Notification',
body: 'You have a new notification',
};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url ?? '/' },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' },
],
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
const url = event.notification.data?.url ?? '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
// Focus existing window if available
for (const client of clients) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
// Otherwise open new window
return self.clients.openWindow(url);
}),
);
});
Testing Your PWA
- Chrome DevTools > Application tab: Check manifest, service worker status, and cache storage
- Lighthouse: Run a PWA audit to check all requirements
- Offline testing: In DevTools Network tab, check “Offline” and navigate your site
- Install testing: On Android, Chrome shows install prompts automatically. On desktop, look for the install icon in the address bar
Summary
Building a PWA is a series of concrete steps: create a manifest for installability, register a service worker for offline support, implement caching strategies that match your data freshness needs, handle the install prompt gracefully, and add push notifications for re-engagement. Each piece works independently, so you can add them incrementally. Start with the manifest and a basic cache-first service worker, then layer on the advanced features.
Related articles
- Web Progressive Web Apps: An Introduction
Learn what makes an app a PWA: service workers, manifests, install prompts, and offline-first strategies that turn a website into something that feels native.
- Web Service Workers: A Deep Dive
How service workers actually run: lifecycle, fetch interception, caching strategies, updates, and the operational habits that keep them from breaking your site.
- Web Progressive Web Apps (PWA): What They Are and When To Build One
A practical introduction to PWAs: manifests, service workers, offline caching, install prompts, and when a PWA is the right choice versus a native app.
- Web Advanced Browser DevTools Tips and Tricks
Go beyond console.log: Network throttling, Performance flame charts, Memory snapshots, CSS debugging, JavaScript profiling, and hidden DevTools features that save hours.