App Development: Responsive Web Apps, PWA, and Service Workers
Progressive Web Apps (PWAs) combine the best of web and native apps: they run in the browser, are installable, work offline, and send push notifications. They bring web reach together with native app experience — no app store review process, no 30% commission, no platform-specific codebases.
In this tutorial, you’ll learn how to build a PWA from scratch, covering the manifest, 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 three 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 serves them from the cache.
Caching strategies: Cache-First (static), Network-First (dynamic), Stale-While-Revalidate (hybrid).
End of quick summary!
What Is a Progressive Web App?
A PWA is a web application that:
- Is installable — on desktop and mobile, appears in the app launcher or home screen
- Works offline — Service Workers cache resources and data
- Can send push notifications — even when the app is closed
- Is responsive — adapts to any device (mobile, tablet, desktop)
- Is secure — HTTPS is required for Service Workers
- Provides an app-like experience — 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 the app installs and appears. Without it, there’s no installation.
{
"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"— the app runs without browser UI (address bar, navigation)theme_color— status bar color on mobilepurpose: "maskable"— icon can be shaped to fit different forms (Android)shortcuts— quick actions in the app launcher (right-click on desktop, long-press on mobile)
Link 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 iOS-specific meta tags? Safari doesn’t fully support the manifest. The apple-* tags ensure the app installs and displays correctly on iOS.
2. Service Worker — The Engine
The Service Worker is a JavaScript worker that runs in the background independently of the web page. It acts as a proxy between your app and the network: every request passes through it, and it decides whether to serve from cache or fetch from the network.
Service Worker lifecycle:
Install → Activate → Fetch/Message Events (ongoing)
↓ ↓
Caching Delete 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
];
// INSTALL: Pre-cache assets upfront
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
.then(() => self.skipWaiting()) // Activate immediately, don't wait
);
});
// ACTIVATE: Clean up 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 → 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) are stored in the cache.
skipWaiting()makes the Service Worker active right away. - Activate: When a new Service Worker version is deployed (
CACHE_NAME = 'my-pwa-v2'), the new worker deletes all old caches. - Fetch: Every request is checked against the cache first. If found, serve it immediately (fast, offline-ready). If not, fetch from the network. If the network is down, show the offline page.
3. Responsive Design
Responsive design isn’t PWA-specific, but it’s essential — your PWA must look great on mobile, tablet, and desktop.
/* Mobile-First: Standard styles for mobile */
.container {
width: 100%;
padding: 1rem;
font-size: 16px;
}
/* Navigation: Hamburger on mobile, horizontal on desktop */
.nav {
display: flex;
flex-direction: column; /* Mobile: stacked */
}
/* 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 principle: Start with mobile styles (smallest screens) and layer on larger breakpoints with min-width media queries. This is more efficient than desktop-first using max-width, since mobile devices need to process less CSS.
Registering a Service Worker
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);
// Detect updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available — notify 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();
}
Key points:
- Service Workers must be served over HTTPS (exception:
localhostduring development) - The Service Worker path determines its scope —
/sw.jscontrols the entire domain - Service Workers are installed on first visit and loaded from cache on subsequent visits
Caching Strategies — Which One When?
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 fetch from the network if not cached.
// Best 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. After a new deployment, update the cache name and purge old caches.
Network-First (for dynamic content)
Try the network first. Fall back to cache if offline.
// Best 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 — serve from cache
return caches.match(event.request);
})
);
});
When to use: Dynamic content that should always be fresh, but needs to be readable offline.
Stale-While-Revalidate (best of both worlds)
Respond immediately from cache (fast) while fetching a fresh version in the background (current).
// Best 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, where a slightly stale version is acceptable. Users get a response instantly, and the latest version loads 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 | ✅ (old cache) | Dynamic content |
| Stale-While-Revalidate | Very fast | Slightly stale | ✅ | Images, fonts |
Multi-Strategy Approach
In practice, 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 (e.g., using the
web-pushnpm package) - HTTPS (required for Service Workers)
iOS limitation: Push notifications on iOS work only on iOS 16.4 and later, and only if the PWA is 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 (todos, notes, user data), 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 an object store (like a table)
const store = db.createObjectStore('todos', { keyPath: 'id' });
// Create 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 vs. IndexedDB:
- Cache API: For HTTP responses (HTML, CSS, JS, images). Key = request, value = response.
- IndexedDB: For structured data (JSON objects, user data, 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 as soon as the connection returns:
// 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 }
}
}
]
}
})
]
};
Benefit: Vite PWA automatically generates the Service Worker with Workbox. You don’t need to write a custom 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 includes an official PWA integration:
npx astro add astro-pwa
Testing PWAs
Lighthouse Audit
Lighthouse checks PWA compliance and provides a score:
npx lighthouse https://localhost:3000 --view --preset=pwa
Lighthouse validates:
- Manifest is present and correct
- Service Worker is registered
- HTTPS is active
- Responsive on mobile
- Offline functionality
- Installability
Service Worker Debugging
Chrome DevTools → Application → Service Workers:
- View status (active, waiting, installed)
- Enable “Update on reload” during development
- Use “Bypass for network” to disable the Service Worker
- Select “Unregister” to remove the Service Worker
Manual Offline Testing
Chrome DevTools → Application → Service Workers → enable the “Offline” checkbox. Reload the page and verify that offline mode works.
Detecting App Installation
You can intercept the install prompt and display a custom “Install” button:
let deferredPrompt;
// Browser fires beforeinstallprompt (before the automatic prompt)
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Prevent automatic prompt
deferredPrompt = e;
showInstallButton(); // Display 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 installed successfully');
// Send analytics event
});
Important: beforeinstallprompt does not fire on iOS. iOS users must manually select “Add to Home Screen”.
Key Concepts for Review
- PWA: Progressive Web App = installable + offline + responsive + secure (HTTPS)
- Three pillars: Web App Manifest (installation), Service Worker (offline/caching), Responsive Design (all devices)
- Manifest: JSON file with 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 subscription,
pushevent in Service Worker - IndexedDB: Offline database for structured data (versus Cache API for 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, and tools. Limitations exist with 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 PWAs? No, PWAs work with vanilla JavaScript — a manifest and a Service Worker are sufficient. Frameworks like Vite PWA and Next-PWA simplify setup and automatically generate the Service Worker using Workbox.
Do PWAs work on iOS?
Yes, with limitations. Push Notifications are available as of iOS 16.4. Background Sync is not supported. The installation 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 the Service Worker (e.g., v1 → v2), 3) the browser detects the Service Worker change on the next visit and installs the new version. Use skipWaiting() to activate the new version immediately.
What’s the difference between Cache API and IndexedDB? Cache API stores HTTP responses (HTML, CSS, JS, images) — the key is the request, the value is the response. IndexedDB stores structured data (JSON objects) with arbitrary keys and indices. Use Cache API for static assets and IndexedDB for user data and offline changes.
Can I combine PWA with a SPA (React/Vue/Svelte)? Yes, it’s 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.
Recommended 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.



