App Development: Responsive Web Apps, PWAs, and Service Workers
Progressive Web Apps (PWAs) combine the best of web and native apps: they run in the browser, can be installed, work offline, and send push notifications. You get the reach of the web paired with the user experience of a native app—without app store review processes, without 30% commissions, and without platform-specific codebases.
In this tutorial, you’ll learn how to build a PWA from scratch: manifest files, Service Workers, offline data, and push notifications.
TL;DR — PWAs in 90 Seconds
A PWA is a web app that’s installable, works offline, and behaves like a native app.
---
The 3 pillars: Web App Manifest (installability), Service Worker (offline/caching), responsive design (all devices).
Service Worker: JavaScript running in the background that intercepts network requests and answers them from cache.
Caching strategies: Cache-First (static), Network-First (dynamic), Stale-While-Revalidate (hybrid).
End of quick overview!
What is a Progressive Web App?
A PWA is a web application that:
- Can be installed — on desktop and mobile, appears in your app launcher or home screen
- Works offline — Service Workers cache resources and data
- Sends push notifications — even when the app is closed
- Is responsive — adapts to any device (phone, tablet, desktop)
- Runs securely — HTTPS is required for Service Workers
- Feels like an app — fullscreen, splash screens, no browser UI
PWA vs. Native App vs. Web App
| Feature | PWA | Native App | Web App |
|---|---|---|---|
| Installation | Browser prompt | App Store | Not installable |
| Offline | ✅ (Service Worker) | ✅ | ❌ |
| Push Notifications | ✅ | ✅ | ❌ |
| Hardware Access | Limited | Full | Very limited |
| App Store | Optional | Required | Not needed |
| Development | Single codebase | Per platform | Single codebase |
| Updates | Automatic (browser) | App Store review | Automatic |
| Cost | Free | $99/year (Apple), $25 (Google) | Free |
The Three Pillars of a PWA
1. Web App Manifest
The manifest is a JSON file that defines how your app installs and appears. No manifest means no installation capability.
{
"name": "My PWA",
"short_name": "PWA",
"description": "A progressive web app with offline functionality",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "New Note",
"url": "/new",
"icons": [{ "src": "/icons/shortcut.png", "sizes": "96x96" }]
}
]
}
Key fields explained:
display: "standalone"— app runs without browser UI (address bar, navigation buttons)theme_color— controls the color of the status bar on mobilepurpose: "maskable"— icon can be scaled to fit different shapes (especially on Android)shortcuts— quick actions available from the app launcher (right-click on desktop, long-press on mobile)
Include it in your HTML:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#000000" />
<!-- iOS-specific: -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
Why the iOS-specific meta tags? Safari only partially supports the manifest spec. The apple-* tags ensure your app installs and renders correctly on iOS devices.
2. Service Worker — The Core
A Service Worker is a JavaScript worker that runs in the background, independent of the web page itself. It acts as a proxy between your app and the network: every request passes through the Service Worker, which decides whether to respond from cache or from the network.
Service Worker lifecycle:
Install → Activate → Fetch/Message Events (running)
↓ ↓
Caching Clean old caches
// sw.js — The Service Worker
const CACHE_NAME = 'my-pwa-v1';
const ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/icons/icon-192.png',
'/offline.html' // Fallback page
];
// INSTALLATION: Pre-cache assets on first visit
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
.then(() => self.skipWaiting()) // Activate immediately, don't wait
);
});
// ACTIVATION: Remove old caches (versioning)
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys
.filter(key => key !== CACHE_NAME) // All except current cache
.map(key => caches.delete(key)) // Delete them
);
}).then(() => self.clients.claim()) // Take control immediately
);
});
// FETCH: Cache-First strategy (for static assets)
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response; // From cache
}
// Not in cache → fetch from network
return fetch(event.request).catch(() => {
// Network unavailable → serve offline fallback
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});
What’s happening here:
- Install: On first visit, all static assets (HTML, CSS, JS, icons) get stored in cache.
skipWaiting()ensures the Service Worker activates immediately. - Activate: When you deploy a new Service Worker version (
CACHE_NAME = 'my-pwa-v2'), the new worker deletes all old caches. - Fetch: Every request checks the cache first. Found → respond from cache (fast, works offline). Not found → fetch from network. Network down → show offline page.
3. Responsive Design
Responsive design isn’t PWA-specific, but it’s essential—your PWA must look good on mobile, tablet, and desktop.
/* Mobile-First: Base styles target mobile devices */
.container {
width: 100%;
padding: 1rem;
font-size: 16px;
}
/* Navigation: Hamburger on mobile, horizontal on desktop */
.nav {
display: flex;
flex-direction: column; /* Mobile: stacked vertically */
}
/* Tablet (768px+) */
@media (min-width: 768px) {
.container {
max-width: 720px;
margin: 0 auto;
}
.nav {
flex-direction: row; /* Tablet: side by side */
}
}
/* Desktop (1024px+) */
@media (min-width: 1024px) {
.container {
max-width: 960px;
}
}
/* Large desktop (1440px+) */
@media (min-width: 1440px) {
.container {
max-width: 1200px;
}
}
Mobile-first approach: Start with styles for the smallest screens, then use min-width media queries to enhance for larger devices. This is more efficient than desktop-first with max-width, because mobile devices download less CSS.
Registering a Service Worker
You need to register the Service Worker from your main app:
// app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered:', registration.scope);
// Update detection
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available — notify the user
showUpdateNotification();
}
});
});
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
// Apply update
function applyUpdate() {
navigator.serviceWorker.getRegistration().then(reg => {
if (reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
window.location.reload();
}
Important:
- Service Workers must be served over HTTPS (exception:
localhostfor development) - The Service Worker’s path determines its scope —
/sw.jscontrols the entire domain - Service Workers install on the first visit and load from cache on subsequent visits
Caching Strategies — Which One for What?
Choosing the right caching strategy is critical for your PWA’s performance and offline capabilities.
Cache-First (for static assets)
Always respond from cache if available. Only go to the network if the cache misses.
// Ideal for: CSS, JS, fonts, icons — files that rarely change
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});
When to use: Static assets that don’t change between deployments. When you deploy a new version, change the cache name and purge old caches.
Network-First (for dynamic content)
Try the network first. If offline, fall back to cache.
// Ideal for: API requests, news feeds, user data
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then(response => {
// Store successful response in cache
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => {
// Offline — return from cache
return caches.match(event.request);
})
);
});
When to use: Dynamic content that should always be fresh but can display older cached data when offline.
Stale-While-Revalidate (best of both worlds)
Respond immediately from cache (fast) while fetching a fresh version in the background (current).
// Ideal for: images, fonts, non-critical assets
self.addEventListener('fetch', (event) => {
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;
}).catch(() => cached);
return cached || fetchPromise;
})
);
});
When to use: Assets that change occasionally but where slightly outdated content is acceptable. Users get an instant response, and they’ll see the latest version on their next visit.
Strategy Comparison
| Strategy | Speed | Freshness | Offline | Best for |
|---|---|---|---|---|
| Cache-First | Very fast | Stale until update | ✅ | Static assets |
| Network-First | Slow (network) | Always current | ✅ (older cache) | Dynamic content |
| Stale-While-Revalidate | Very fast | Slightly stale | ✅ | Images, fonts |
Multi-Strategy Approach
In practice, you’ll use different strategies for different request types:
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API requests: Network-First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
return;
}
// Images: Stale-While-Revalidate
if (event.request.destination === 'image') {
event.respondWith(staleWhileRevalidate(event.request));
return;
}
// Everything else: Cache-First
event.respondWith(cacheFirst(event.request));
});
Push Notifications
Push notifications let your PWA reach users even when the app is closed. This requires a push service like Firebase Cloud Messaging or Web Push.
// 1. Request permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('User denied notifications');
return;
}
// 2. Create push subscription
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // Notifications must be visible
applicationServerKey: VAPID_PUBLIC_KEY // Public VAPID key
});
// 3. Send subscription to server
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
console.log('Push subscription registered');
}
// 4. Receive in Service Worker
self.addEventListener('push', (event) => {
const data = event.data.json();
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: 'close', title: 'Close' }
]
})
);
});
// 5. Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open' || !event.action) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
What you need:
- VAPID keys (generate with
npx web-push generate-vapid-keys) - A server that sends push notifications (using the
web-pushnpm package, for example) - HTTPS (required for Service Workers)
iOS limitation: Push notifications on iOS work starting from iOS 16.4 and only when the PWA has been added to the home screen.
Offline Data with IndexedDB
The Cache API works well for static assets, but not for structured data. For offline data like todos, notes, or user information, use IndexedDB.
// IndexedDB is async and callback-based — idb makes it Promise-based
// npm install idb
import { openDB } from 'idb';
const db = await openDB('my-pwa-db', 1, {
upgrade(db) {
// Create object store (like a table)
const store = db.createObjectStore('todos', { keyPath: 'id' });
// Indexes for faster queries
store.createIndex('by-status', 'status');
store.createIndex('by-date', 'createdAt');
}
});
// Save data (offline)
async function saveTodo(todo) {
await db.put('todos', {
id: crypto.randomUUID(),
title: todo.title,
status: 'pending',
createdAt: Date.now()
});
}
// Read data
async function getTodos() {
return await db.getAll('todos');
}
// Filter by status
async function getPendingTodos() {
return await db.getAllFromIndex('todos', 'by-status', 'pending');
}
// Delete data
async function deleteTodo(id) {
await db.delete('todos', id);
}
Cache API vs. IndexedDB:
- Cache API: For HTTP responses (HTML, CSS, JS, images). Key = request, value = response.
- IndexedDB: For structured data (JSON objects, user information, offline changes). Key = any key, value = any object.
Background Sync (Synchronizing Offline Changes)
When users make changes while offline, Background Sync can automatically synchronize them once the connection is restored:
// In the app: register sync
async function syncTodos() {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('sync-todos');
}
// In the Service Worker: handle sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-todos') {
event.waitUntil(syncTodosWithServer());
}
});
async function syncTodosWithServer() {
const pendingTodos = await db.getAllFromIndex('todos', 'by-status', 'pending');
for (const todo of pendingTodos) {
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
await db.put('todos', { ...todo, status: 'synced' });
}
}
PWAs with Frameworks
Vite PWA Plugin (recommended for Vite projects)
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate', // automatic updates
manifest: {
name: 'My PWA',
short_name: 'PWA',
theme_color: '#000000',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 3600 }
}
}
]
}
})
]
};
Advantage: Vite PWA automatically generates the Service Worker using Workbox. You don’t need to write a Service Worker manually.
Next.js PWA
npx create-next-app@latest my-pwa --typescript
npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
});
module.exports = withPWA({});
Astro PWA
Astro provides an official PWA integration:
npx astro add astro-pwa
Testing Your PWA
Lighthouse Audit
Lighthouse validates PWA compliance and provides a score:
npx lighthouse https://localhost:3000 --view --preset=pwa
Lighthouse checks for:
- Manifest present and valid
- Service Worker registered
- HTTPS enabled
- Responsive design on mobile
- Offline functionality
- Installability
Service Worker Debugging
In Chrome DevTools → Application → Service Workers, you can:
- View status (active, waiting, installed)
- Enable “Update on reload” during development
- Use “Bypass for network” to disable the Service Worker
- Click “Unregister” to remove the Service Worker
Manual Offline Testing
Open Chrome DevTools → Application → Service Workers and check the “Offline” checkbox. Reload the page and verify that offline mode works as expected.
Detecting App Installation
You can intercept the install prompt and show a custom “Install” button:
let deferredPrompt;
// Browser fires beforeinstallprompt before showing the automatic prompt
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // prevent automatic prompt
deferredPrompt = e;
showInstallButton(); // show custom button
});
// User clicks the install button
installButton.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt(); // show installation dialog
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User installed the app');
hideInstallButton();
} else {
console.log('User declined installation');
}
deferredPrompt = null; // prompt can only be used once
});
// App was installed
window.addEventListener('appinstalled', () => {
console.log('App successfully installed');
// send analytics event
});
Important: beforeinstallprompt does not fire on iOS. iOS users must manually select “Add to Home Screen” from the share menu.
Key Exam Topics
- PWA: Progressive Web App = installable + offline + responsive + secure (HTTPS)
- 3 Pillars: Web App Manifest (installation), Service Worker (offline/caching), Responsive Design (all devices)
- Manifest: JSON file containing name, icons, display, theme_color, start_url
- Service Worker Lifecycle: Install (pre-cache) → Activate (delete old caches) → Fetch (intercept requests)
- Caching Strategies: Cache-First (static), Network-First (dynamic), Stale-While-Revalidate (hybrid)
- Push Notifications: VAPID keys, push subscriptions,
pushevent in Service Worker - IndexedDB: Offline database for structured data (unlike Cache API which stores HTTP responses)
- Background Sync: Automatic synchronization of offline changes
- HTTPS: Required for Service Worker (exception: localhost)
- Testing: Lighthouse PWA Audit, Chrome DevTools Application Tab
FAQ
Are PWAs a replacement for native apps? For many use cases, yes—especially content apps, e-commerce sites, and tools. Limitations exist for hardware access (Bluetooth, NFC, sensors), background processes, and app store presence. Games and hardware-intensive apps still benefit from native implementations.
Do I need a framework to build a PWA? No. PWAs work with vanilla JavaScript—a manifest and a Service Worker are all you need. Frameworks like Vite PWA and Next-PWA simplify setup and automatically generate the Service Worker using Workbox.
Do PWAs work on iOS?
Yes, with caveats. Push Notifications have been available since iOS 16.4. Background Sync is not supported. The install prompt requires manual action via “Share → Add to Home Screen” (no beforeinstallprompt event).
How do I update my PWA?
On each deployment: 1) upload new assets, 2) increment the cache name in your Service Worker (e.g., v1 → v2), 3) the browser detects the Service Worker change on the next visit and installs the new version. Using skipWaiting() makes the new version active immediately.
What’s the difference between the Cache API and IndexedDB? Cache API stores HTTP responses (HTML, CSS, JS, images)—the key is the request and the value is the response. IndexedDB stores structured data (JSON objects) with arbitrary keys and indexes. Use Cache API for static assets and IndexedDB for user data and offline changes.
Can I combine PWA with SPA (React/Vue/Svelte)? Yes, this is the standard approach. The SPA runs as a web app, the Service Worker caches the SPA assets, and the manifest makes it installable. Vite PWA Plugin supports React, Vue, Svelte, and other frameworks out of the box.
Further Reading
Web Development
Books about React, Vue, frontend and backend
Eloquent JavaScript von Marijn Haverbeke
Bei Amazon ansehenAffiliate-Link: Bei einem Kauf erhalten wir möglicherweise eine Provision.



