Skip to content
IRC-CodingIRC-Coding
pwaservice-workerresponsive-web-appsofflineweb-manifestapp-development

Progressive Web Apps: PWA, Service Workers & Offline

Build Progressive Web Apps with Service Workers, offline functionality, and responsive design. Practical tutorial with code examples.

I

IRC-Coding Team

12 min read
Progressive Web Apps: PWA, Service Workers & Offline

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

FeaturePWANative AppWeb App
InstallationBrowser promptApp StoreNot installable
Offline✅ (Service Worker)
Push Notifications
Hardware AccessLimitedFullVery limited
App StoreOptionalRequiredNot needed
DevelopmentSingle codebasePer platformSingle codebase
UpdatesAutomatic (browser)App Store reviewAutomatic
CostFree$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 mobile
  • purpose: "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:

  1. Install: On first visit, all static assets (HTML, CSS, JS, icons) get stored in cache. skipWaiting() ensures the Service Worker activates immediately.
  2. Activate: When you deploy a new Service Worker version (CACHE_NAME = 'my-pwa-v2'), the new worker deletes all old caches.
  3. 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: localhost for development)
  • The Service Worker’s path determines its scope — /sw.js controls 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

StrategySpeedFreshnessOfflineBest for
Cache-FirstVery fastStale until updateStatic assets
Network-FirstSlow (network)Always current✅ (older cache)Dynamic content
Stale-While-RevalidateVery fastSlightly staleImages, 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-push npm 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.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, push event 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., v1v2), 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

Eloquent JavaScript von Marijn Haverbeke

Bei Amazon ansehen

Affiliate-Link: Bei einem Kauf erhalten wir möglicherweise eine Provision.

Back to Blog
Share:

Related Posts