Progressive Web Apps have moved from experimental technology to an industry standard. In 2025, companies like Starbucks, Pinterest, and Twitter rely on PWAs to deliver fast, reliable, and engaging experiences that rival native apps — without the App Store gatekeeping. This guide walks you through every aspect of building a production-ready PWA from scratch.
What Exactly Is a PWA?
A Progressive Web App is not a framework or a library you install with npm. It is a set of web technologies and best practices that, when combined, give your website superpowers: offline access, push notifications, home screen installation, and background sync. The three pillars that define a PWA are:
- Reliable: Loads instantly even on flaky networks, never showing the Chrome dinosaur.
- Fast: Responds quickly to user interactions with smooth animations and no janky scrolling.
- Engaging: Feels like a native app on the device, with an immersive full-screen experience.
Under the hood, a PWA requires three core pieces: a Web App Manifest (metadata for installation), a Service Worker (a JavaScript proxy that intercepts network requests), and HTTPS (mandatory for security).
Step 1: The Web App Manifest
The manifest is a JSON file that tells the browser about your application. It controls the app name, icons, splash screen, theme color, and how the app launches. Create a file called manifest.json in your project root:
{
"name": "DreamWebCrafts Portal",
"short_name": "DreamWeb",
"description": "Professional web development services and tools",
"start_url": "/dashboard?source=pwa",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#6366f1",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "New Project",
"short_name": "New",
"url": "/projects/new",
"icons": [{ "src": "/icons/shortcut-new.png", "sizes": "96x96" }]
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Dashboard view on desktop"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "Dashboard view on mobile"
}
]
}
Link the manifest in your HTML <head>:
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">
<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-152x152.png">
The display: "standalone" makes the app launch without the browser's address bar, giving it a native feel. The purpose: "any maskable" on icons ensures Android devices can apply their adaptive icon shapes without cropping your logo.
Step 2: Service Worker Lifecycle
The service worker is the backbone of any PWA. It is a JavaScript file that runs in the background, separate from your web page. It cannot access the DOM directly, but it can intercept every network request your page makes. Understanding its lifecycle is critical.
Registration
First, register the service worker from your main JavaScript file:
// app.js - Register the service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('SW registered with scope:', registration.scope);
// Check for updates every 60 minutes
setInterval(() => {
registration.update();
}, 60 * 60 * 1000);
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
Install Event
The install event fires when the browser detects a new or updated service worker file. This is where you pre-cache your application shell — the minimal HTML, CSS, and JavaScript needed to render the UI:
// sw.js
const CACHE_NAME = 'dreamweb-v2.1.0';
const STATIC_ASSETS = [
'/',
'/index.html',
'/css/app.min.css',
'/js/app.bundle.js',
'/images/logo.svg',
'/offline.html',
'/fonts/inter-var.woff2'
];
self.addEventListener('install', (event) => {
console.log('[SW] Install event');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Pre-caching app shell');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting()) // Activate immediately
);
});
Activate Event
The activate event fires after installation when the service worker takes control. Use this to clean up old caches from previous versions:
self.addEventListener('activate', (event) => {
console.log('[SW] Activate event');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim()) // Take control of all pages
);
});
Fetch Event — The Network Proxy
This is where the real power lies. Every HTTP request from your page goes through the fetch event handler, and you decide how to respond.
Step 3: Caching Strategies Deep Dive
Choosing the right caching strategy for each resource type is the difference between a PWA that feels magical and one that serves stale content. Here are the four strategies you need to know:
Strategy 1: Cache First (Cache Falling Back to Network)
Best for: static assets like CSS, JS bundles, images, fonts — things that rarely change.
// Cache First Strategy
function cacheFirst(request) {
return caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse; // Serve from cache instantly
}
return fetch(request).then((networkResponse) => {
// Cache the new response for next time
return caches.open(CACHE_NAME).then((cache) => {
cache.put(request, networkResponse.clone());
return networkResponse;
});
});
});
}
Strategy 2: Network First (Network Falling Back to Cache)
Best for: API responses, dynamic content, user-specific data — things that should always be fresh.
// Network First Strategy
function networkFirst(request) {
return fetch(request)
.then((networkResponse) => {
// Update cache with fresh response
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return networkResponse;
})
.catch(() => {
// Network failed, serve from cache
return caches.match(request);
});
}
Strategy 3: Stale While Revalidate
Best for: content that updates but does not need to be real-time — blog posts, profile photos, social feeds.
// Stale While Revalidate Strategy
function staleWhileRevalidate(request) {
return caches.open(CACHE_NAME).then((cache) => {
return cache.match(request).then((cachedResponse) => {
const fetchPromise = fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
});
// Return cache immediately, update in background
return cachedResponse || fetchPromise;
});
});
}
Strategy 4: Cache Only
Best for: offline-only assets that you pre-cached during install and never need to update at runtime.
// Cache Only Strategy
function cacheOnly(request) {
return caches.match(request);
}
Putting It All Together in the Fetch Handler
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API calls: Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: Cache First
if (request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image') {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages: Stale While Revalidate with offline fallback
if (request.mode === 'navigate') {
event.respondWith(
staleWhileRevalidate(request).catch(() => {
return caches.match('/offline.html');
})
);
return;
}
// Default: Network First
event.respondWith(networkFirst(request));
});
Step 4: Offline Fallback Page
Create a beautiful offline page so users know they are disconnected but still feel engaged:
<!-- 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 - DreamWebCrafts</title>
<style>
body {
font-family: 'Inter', system-ui, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; margin: 0;
background: #0a0a0a; color: #e5e5e5;
}
.container { text-align: center; padding: 2rem; }
.icon { font-size: 4rem; margin-bottom: 1rem; }
h1 { color: #6366f1; }
.retry-btn {
margin-top: 1.5rem; padding: 12px 32px;
background: #6366f1; color: white; border: none;
border-radius: 8px; cursor: pointer; font-size: 1rem;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📡</div>
<h1>You're Offline</h1>
<p>It looks like your internet connection dropped. Don't worry — previously visited pages are still available.</p>
<button class="retry-btn" onclick="window.location.reload()">Try Again</button>
</div>
</body>
</html>
Step 5: Push Notifications Setup
Push notifications re-engage users even when the browser is closed. This requires a push service (Firebase Cloud Messaging is the easiest), VAPID keys, and a backend to send the notifications.
Generate VAPID Keys
# Install web-push globally
npm install -g web-push
# Generate VAPID keys
web-push generate-vapid-keys --json
This outputs a public and private key pair. Store the private key on your server securely and expose the public key to the client.
Subscribe the User (Client Side)
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkOs-qH...'
)
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
Handle Push in Service Worker
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || 'You have a new notification',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [100, 50, 100],
data: { url: data.url || '/' },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title || 'DreamWebCrafts', options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Send Push from Server (Node.js)
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:admin@dreamwebcrafts.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
async function sendPushNotification(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
console.log('Push sent successfully');
} catch (error) {
if (error.statusCode === 410) {
// Subscription expired, remove from database
await removeSubscription(subscription.endpoint);
}
}
}
// Usage
sendPushNotification(userSubscription, {
title: 'Your project is live!',
body: 'DreamWebCrafts has deployed your website successfully.',
url: '/projects/123'
});
Step 6: Workbox Integration
Writing service workers by hand is error-prone. Google's Workbox library provides battle-tested caching strategies, precaching with versioning, and a build tool that auto-generates the file manifest.
# Install Workbox CLI
npm install workbox-cli --save-dev
# Generate a service worker config
npx workbox wizard
Workbox configuration file:
// workbox-config.js
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{html,js,css,png,svg,woff2,webp}'
],
swDest: 'dist/sw.js',
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https://api.dreamwebcrafts.com/.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
},
networkTimeoutSeconds: 3
}
},
{
urlPattern: /.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
}
}
},
{
urlPattern: /^https://fonts.googleapis.com/.*/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-stylesheets'
}
}
]
};
# Build the service worker
npx workbox generateSW workbox-config.js
Step 7: Making Any Existing Website a PWA
You do not need to rewrite your website from scratch. Here is the step-by-step checklist to convert any existing site into a PWA:
- Add HTTPS: Use Let's Encrypt with Certbot. PWAs require a secure origin. Run:
sudo certbot --nginx -d yourdomain.com - Create manifest.json: Place it in your root directory with proper icons.
- Link the manifest: Add
<link rel="manifest" href="/manifest.json">to every page's<head>. - Create sw.js: Start with a simple cache-first strategy for static assets.
- Register the service worker: Add the registration script to your main JS file.
- Create offline.html: A branded fallback page for when the network is unavailable.
- Add Apple meta tags: iOS Safari does not fully support the manifest, so add
apple-mobile-web-app-capablemeta tags. - Test with Lighthouse: Run the PWA audit in Chrome DevTools to verify everything passes.
Step 8: Debugging PWAs in Chrome DevTools
Chrome DevTools has a dedicated panel for PWA debugging. Open DevTools (F12) and navigate to the Application tab.
- Manifest panel: Shows parsed manifest data, install status, and any warnings about missing icons or fields.
- Service Workers panel: View registered service workers, force update, skip waiting, simulate offline mode, and unregister.
- Cache Storage panel: Inspect every cache your service worker created and the individual files stored in each cache.
- Background Services: Monitor push messages, background sync events, and periodic background sync.
Common Debugging Commands
# Open Chrome DevTools Console
# Check if service worker is registered
navigator.serviceWorker.getRegistrations().then(r => console.log(r));
# Unregister all service workers
navigator.serviceWorker.getRegistrations().then(regs =>
regs.forEach(reg => reg.unregister())
);
# Clear all caches
caches.keys().then(names =>
names.forEach(name => caches.delete(name))
);
PWA vs Native App: Comparison Table
| Feature | PWA | Native App |
|---|---|---|
| Distribution | URL / Browser | App Store / Play Store |
| Installation | Add to Home Screen prompt | Download from store |
| Update Mechanism | Automatic (service worker) | Manual / store update |
| Offline Support | Yes (service worker cache) | Yes (native storage) |
| Push Notifications | Yes (Web Push API) | Yes (APNs / FCM) |
| Device APIs | Camera, GPS, Bluetooth (limited) | Full access |
| Performance | Near-native with optimizations | Best possible |
| Development Cost | 1 codebase (low) | iOS + Android (high) |
| Discoverability | SEO / Google search | App Store search only |
| File Size | Under 1MB typically | 50-200MB average |
Lighthouse PWA Audit Checklist
Google Lighthouse scores your PWA on specific criteria. Here is how to hit 100 on the PWA audit:
- Page is served over HTTPS
- Web app manifest meets the installability requirements
- Service worker registered with a fetch event handler
- Responds with 200 when offline (offline fallback page)
start_urlresponds with 200 when offline- Page has a valid
apple-touch-icon - Configured for a custom splash screen
- Sets a theme color for the address bar
- Content is sized correctly for the viewport
- Has a
<meta name="viewport">tag withwidthorinitial-scale
# Run Lighthouse from CLI
npm install -g lighthouse
# Audit a URL
lighthouse https://yoursite.com --output html --output-path ./report.html
# PWA-specific audit only
lighthouse https://yoursite.com --only-categories=pwa
Troubleshooting Common PWA Errors
Problem: Service Worker Not Updating
Cause: The browser caches the service worker file itself for up to 24 hours. Changes to sw.js may not be reflected immediately.
Solution: Change the CACHE_NAME version string in your service worker (e.g., from v2.1.0 to v2.1.1). In DevTools, check "Update on reload" in the Service Workers panel. In production, set Cache-Control: no-cache on the sw.js file in your server configuration.
Problem: "Add to Home Screen" Prompt Not Showing
Cause: Chrome has strict criteria. You need HTTPS, a manifest with required fields, a registered service worker with a fetch handler, and the user must have engaged with the site for at least 30 seconds.
Solution: Verify all manifest fields are correct. Ensure the service worker is registered and actively handling fetch events. Listen for the beforeinstallprompt event to trigger the prompt at the right time:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show your custom install button
document.getElementById('installBtn').style.display = 'block';
});
document.getElementById('installBtn').addEventListener('click', async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('User response:', outcome);
deferredPrompt = null;
});
Problem: Cached API Responses Showing Stale Data
Cause: You are using Cache First for API endpoints that return dynamic data.
Solution: Switch API routes to Network First with a timeout fallback. Use the networkTimeoutSeconds option in Workbox so if the network does not respond within 3 seconds, the cached version is served.
Problem: iOS Safari Not Caching Properly
Cause: Safari has a 50MB cache limit per origin and evicts service worker caches after 7 days of inactivity.
Solution: Keep your cached assets under 50MB total. Implement periodic re-caching logic that refreshes critical assets. Add Apple-specific meta tags since Safari ignores parts of the manifest.
Quick Reference: PWA Cheat Sheet
| Task | Command / Code |
|---|---|
| Generate VAPID keys | web-push generate-vapid-keys --json |
| Run Lighthouse PWA audit | lighthouse https://site.com --only-categories=pwa |
| Build Workbox SW | npx workbox generateSW workbox-config.js |
| Get SSL Certificate | sudo certbot --nginx -d domain.com |
| Unregister all SWs (console) | navigator.serviceWorker.getRegistrations().then(r => r.forEach(sw => sw.unregister())) |
| Clear all caches (console) | caches.keys().then(n => n.forEach(k => caches.delete(k))) |
| Force SW update | DevTools > Application > Service Workers > Update |
| Test offline mode | DevTools > Network tab > check "Offline" |
Progressive Web Apps represent the future of web delivery. They combine the best of web and native, cut distribution costs, and provide measurable improvements in engagement. At DreamWebCrafts, we have converted dozens of client websites into full-featured PWAs, and the results speak for themselves: faster load times, higher engagement, and offline capabilities that keep users coming back. Need help turning your website into a PWA? Our team specializes in PWA architecture and service worker optimization — get in touch for a free consultation.