MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Progressive Web Apps (PWA): From Zero to Hero

Build a PWA: service workers, web manifest, offline support, and push notifications.

PWAService WorkersOfflineFrontend

By MinhVo

Introduction

Progressive Web Apps represent one of the most significant shifts in how we build and deliver web applications. By combining the reach and accessibility of the web with capabilities traditionally reserved for native apps — offline support, push notifications, home screen installation, and hardware access — PWAs offer a compelling middle ground that eliminates the need for most companies to build and maintain separate native applications. Companies like Twitter, Starbucks, Pinterest, and Uber have seen measurable improvements in engagement, conversion rates, and load times after adopting PWA architectures.

The PWA concept is built on three foundational pillars: a web manifest that describes the application to the operating system, a service worker that acts as a programmable network proxy enabling offline support and background processing, and HTTPS that ensures all communication is secure. Together, these technologies enable a web application to be installed on a user's device, launch from the home screen in its own window, and function reliably regardless of network conditions.

This guide takes you from zero to production-ready PWA, covering every layer from manifest configuration through service worker lifecycle management, caching strategies, background sync, push notifications, and performance optimization techniques that make your PWA feel indistinguishable from a native application.

Progressive Web App architecture

Understanding PWAs: Core Concepts

A Progressive Web App is not a specific framework or library — it is an architectural pattern that leverages a set of modern web APIs to deliver app-like experiences. The term "progressive" refers to the fact that PWAs work for every user regardless of browser choice, progressively enhancing from a basic web page to a full app-like experience as browser capabilities allow.

The Web App Manifest

The manifest is a JSON file that tells the browser how your application should behave when installed on a device. It defines the application name, icons, theme colors, display mode, and start URL. When a user visits your PWA and the browser detects a valid manifest alongside a registered service worker, it triggers an installation prompt.

{
  "name": "Task Manager Pro",
  "short_name": "TaskMgr",
  "description": "A powerful offline-first task management application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a73e8",
  "orientation": "portrait-primary",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ],
  "categories": ["productivity", "utilities"],
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "New",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/add.png", "sizes": "96x96" }]
    }
  ]
}

The Service Worker Lifecycle

A service worker is a JavaScript file that runs in a background thread, separate from the web page. It cannot access the DOM directly but can intercept network requests, cache resources, and handle push events. The lifecycle consists of three phases: registration, installation, and activation.

During installation, the service worker pre-caches critical assets. During activation, it cleans up old caches. Once active, it intercepts all fetch events from the page, enabling you to implement caching strategies, offline fallbacks, and request modification.

// sw.js — Service Worker file
const CACHE_NAME = 'task-manager-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/app.js',
  '/offline.html',
  '/icons/icon-192.png',
];
 
// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting();
});
 
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  self.clients.claim();
});
 
// Fetch: serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).catch(() => {
        if (event.request.destination === 'document') {
          return caches.match('/offline.html');
        }
      });
    })
  );
});

HTTPS Requirement

Service workers can only be registered on pages served over HTTPS (or localhost for development). This is a security requirement because service workers have the powerful ability to intercept and modify network requests. In production, ensure your server is configured with a valid TLS certificate — most hosting platforms handle this automatically.

Service worker lifecycle diagram

Architecture and Design Patterns

Cache-First Strategy

The cache-first strategy serves resources from the cache whenever available, falling back to the network only on cache misses. This is ideal for static assets like CSS, JavaScript, images, and fonts that change infrequently.

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
 
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
  }
  return response;
}

Network-First Strategy

The network-first strategy attempts to fetch from the network, falling back to the cache when offline. This is appropriate for data that needs to be fresh but should still be available offline.

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    return caches.match(request);
  }
}

Stale-While-Revalidate Strategy

This strategy serves from the cache immediately while updating the cache in the background. It provides the best user experience for resources that change occasionally — the user always gets a fast response while the cache stays reasonably fresh.

async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
 
  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });
 
  return cached || fetchPromise;
}

Runtime Caching Router

A production service worker needs different strategies for different types of requests. A runtime router maps URL patterns to caching strategies.

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
 
  // Static assets: cache-first
  if (url.pathname.match(/\.(css|js|woff2|png|jpg|svg)$/)) {
    event.respondWith(cacheFirst(request));
    return;
  }
 
  // API calls: network-first
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }
 
  // Images: stale-while-revalidate
  if (url.pathname.startsWith('/images/')) {
    event.respondWith(staleWhileRevalidate(request));
    return;
  }
 
  // Navigation: network-first with offline fallback
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request));
    return;
  }
 
  event.respondWith(fetch(request));
});

Step-by-Step Implementation

Registering the Service Worker

The first step in any PWA is registering the service worker from your application's main JavaScript file. The registration process is asynchronous and should happen after the page loads.

// app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });
 
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'activated') {
            showUpdateNotification();
          }
        });
      });
 
      console.log('Service Worker registered:', registration.scope);
    } catch (error) {
      console.error('Service Worker registration failed:', error);
    }
  });
}

Implementing Offline Data Storage

For a PWA to work offline, you need to store data locally. The IndexedDB API provides a robust client-side database for structured data. Libraries like idb provide a Promise-based wrapper that makes IndexedDB much easier to use.

import { openDB } from 'idb';
 
const dbPromise = openDB('task-manager', 1, {
  upgrade(db) {
    const taskStore = db.createObjectStore('tasks', { keyPath: 'id' });
    taskStore.createIndex('status', 'status');
    taskStore.createIndex('dueDate', 'dueDate');
 
    db.createObjectStore('sync-queue', { keyPath: 'id', autoIncrement: true });
  },
});
 
export async function saveTask(task) {
  const db = await dbPromise;
  await db.put('tasks', task);
 
  // Queue for sync when online
  if (!navigator.onLine) {
    await db.add('sync-queue', {
      type: 'SAVE_TASK',
      payload: task,
      timestamp: Date.now(),
    });
  }
}
 
export async function getAllTasks() {
  const db = await dbPromise;
  return db.getAllFromIndex('tasks', 'status');
}
 
export async function getPendingSync() {
  const db = await dbPromise;
  return db.getAll('sync-queue');
}

Background Sync for Offline Actions

Background Sync defers requests until the user has connectivity, ensuring that actions taken offline are not lost. When connectivity is restored, the browser triggers a sync event that your service worker handles.

// In the main app code
async function saveTaskOffline(task) {
  await saveTask(task);
 
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-tasks');
  }
}
 
// In the service worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-tasks') {
    event.waitUntil(syncPendingTasks());
  }
});
 
async function syncPendingTasks() {
  const db = await openDB('task-manager', 1);
  const pending = await db.getAll('sync-queue');
 
  for (const item of pending) {
    try {
      await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item.payload),
      });
      await db.delete('sync-queue', item.id);
    } catch (error) {
      console.error('Sync failed for item:', item.id, error);
    }
  }
}

Push Notifications

Push notifications keep users engaged even when the PWA is not open. The Push API combined with the Notifications API enables server-initiated messages that appear as native-style notifications.

// Request notification permission and subscribe
async function subscribeToPush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;
 
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });
 
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
}
 
// Service worker: handle push events
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'New notification', body: '' };
 
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge.png',
      data: data.url,
      actions: data.actions ?? [],
    })
  );
});
 
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data || '/')
  );
});

Adding the Manifest to Your HTML

Link the manifest in your HTML <head> and include meta tags for theme colors and viewport configuration:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#1a73e8">
  <meta name="description" content="Offline-first task management PWA">
  <link rel="manifest" href="/manifest.json">
  <link rel="apple-touch-icon" href="/icons/icon-192.png">
  <title>Task Manager Pro</title>
</head>
<body>
  <div id="app"></div>
  <script src="/app.js"></script>
</body>
</html>

PWA implementation workflow

Real-World Use Cases

Use Case 1: Offline-First News Application

A news PWA pre-caches the latest articles during installation and uses a stale-while-revalidate strategy for article content. When the user opens the app without connectivity, they see the last fetched articles with a banner indicating offline mode. Background sync uploads reading preferences and bookmarks when connectivity returns.

Use Case 2: Field Service Management App

Technicians working in areas with poor connectivity use a PWA to access work orders, record service notes, and capture photos. The app syncs data to IndexedDB and queues updates for background sync. Push notifications alert technicians to urgent assignments even when the app is not open. The install prompt allows the app to live on the home screen alongside native apps.

Use Case 3: E-Commerce Storefront with Offline Browsing

An e-commerce PWA uses cache-first for product images and stale-while-revalidate for product data. Users can browse the catalog offline, add items to a cart, and begin checkout. The service worker intercepts checkout requests and queues them for background sync, completing the purchase when connectivity is restored.

Use Case 4: Real-Time Collaborative Note-Taking

A collaborative notes app combines service worker caching with WebSocket connections for real-time sync. Notes are cached in IndexedDB for instant loading and offline access. When the connection drops, the app continues to function with local edits. On reconnection, a conflict resolution algorithm merges changes from multiple devices.

Best Practices for Production

  1. Cache versioning and cleanup: Use a versioned cache name (e.g., app-v1.2.3) and delete old caches during the activate event. This prevents stale resources from being served after deployments and ensures users always get the latest application code.

  2. Pre-cache only critical assets: Limit the install-time cache to the application shell — HTML, CSS, core JavaScript, and essential icons. Pre-caching too much increases install time and wastes storage. Use runtime caching for user-specific or dynamically loaded content.

  3. Implement update notifications: When a new service worker is available, notify the user and offer a reload. Do not force updates during active sessions as this can disrupt workflows. Use the updatefound event and a user-friendly toast or banner.

  4. Test offline behavior thoroughly: Use Chrome DevTools' Application panel to simulate offline conditions, test cache strategies, and verify service worker lifecycle transitions. Test on real devices with intermittent connectivity, not just the browser's offline toggle.

  5. Respect storage quotas: Browsers allocate limited storage per origin. Use the Storage Manager API to check available space and request persistent storage for critical applications. Implement cache expiration policies to prevent unbounded growth.

  6. Use workbox for production service workers: Google's Workbox library provides production-ready caching strategies, routing, expiration, and background sync. It handles edge cases and browser differences that manual service worker code does not.

  7. Implement proper error boundaries: When a resource is not in the cache and the network fails, serve a meaningful fallback page rather than a browser error. Every navigation request should have a cached fallback.

  8. Optimize for the install prompt: The browser shows an install prompt when it detects engagement signals. Ensure your manifest is valid, the service worker is registered, and the user has interacted with the page at least once before the prompt appears.

Common Pitfalls and Solutions

PitfallImpactSolution
Stale cache after deploymentUsers see outdated content or broken functionalityVersion your caches and delete old ones in the activate event handler
Service worker scope limitationsSW only controls pages under its scope, missing some routesPlace sw.js at the root of your site and set scope: '/' during registration
Cache bloat consuming device storageApp accumulates hundreds of megabytes of cached dataUse cache expiration plugins (e.g., workbox-expiration) and set max entries or max age
Push notifications blocked by browserUsers never receive notifications, defeating engagement strategyRequest permission only after user interaction that implies consent, explain the value first
IndexedDB unavailable in private browsingApp crashes or loses data in incognito modeCheck for IndexedDB support and fall back to in-memory storage with a degraded experience
Mixed content blocking PWA featuresHTTPS requirement causes some features to failEnsure all resources are served over HTTPS; audit with Lighthouse

Performance Optimization

App Shell Architecture

Separate your application into the app shell (the minimal HTML, CSS, and JavaScript needed to render the UI) and dynamic content. The app shell is cached on install and served instantly, while dynamic content loads from the network or runtime cache.

const APP_SHELL = [
  '/',
  '/index.html',
  '/static/css/main.css',
  '/static/js/app.js',
  '/static/js/vendor.js',
  '/offline.html',
];
 
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-shell-v1').then((cache) => cache.addAll(APP_SHELL))
  );
});

Lazy Loading Non-Critical Resources

Load images, below-the-fold content, and secondary features lazily to reduce initial load time and cache storage usage.

// Intersection Observer for lazy image loading
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.removeAttribute('data-src');
      observer.unobserve(img);
    }
  });
});
 
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));

Precaching with Workbox

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
 
precacheAndRoute(self.__WB_MANIFEST);
 
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 })],
  })
);
 
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({ cacheName: 'api', networkTimeoutSeconds: 5 })
);

Comparison with Alternatives

FeaturePWANative App (iOS/Android)React Native / FlutterElectron
InstallationBrowser prompt, no app storeApp store approval requiredApp store approval requiredDownload installer
Offline SupportVia service workers and IndexedDBBuilt-in file system accessBuilt-in with SQLiteBuilt-in with file system
Push NotificationsWeb Push API (limited iOS support)Full native push supportFull native push via bridgesOS-level notifications
Hardware AccessGeolocation, camera, limited BluetoothFull hardware accessMost hardware via bridgesFull hardware access
Update DeliveryInstant on next visitApp store review (hours to days)CodePush for OTA updatesAuto-update or manual download
Development CostSingle codebase, web technologiesSeparate iOS and Android teamsSingle codebase, native bridgesSingle codebase, web technologies
PerformanceGood, but browser overheadBest native performanceNear-native with compiled UIHigher memory usage
ReachAny device with a browseriOS/Android specificiOS and AndroidWindows, macOS, Linux

Advanced Patterns

Periodic Background Sync

Periodic background sync allows your PWA to update content in the background at regular intervals, even when the app is not open. This requires user permission and is currently only available in Chromium browsers.

// Request periodic sync
async function registerPeriodicSync() {
  const registration = await navigator.serviceWorker.ready;
 
  if ('periodicSync' in registration) {
    const status = await navigator.permissions.query({ name: 'periodic-background-sync' });
    if (status.state === 'granted') {
      await registration.periodicSync.register('update-content', {
        minInterval: 24 * 60 * 60 * 1000, // 24 hours
      });
    }
  }
}
 
// Handle periodic sync in service worker
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'update-content') {
    event.waitUntil(updateCachedContent());
  }
});

Share Target API

The Share Target API allows your PWA to appear in the system share sheet, receiving shared text, URLs, and files from other applications.

{
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [
        { "name": "file", "accept": ["image/*", ".pdf"] }
      ]
    }
  }
}

Testing Strategies

Lighthouse Audits

Run Lighthouse in Chrome DevTools or via the CLI to audit PWA compliance. The PWA category checks for a valid manifest, service worker registration, HTTPS usage, and installability criteria.

npx lighthouse https://your-app.com --output=html --view

Service Worker Testing with Puppeteer

import puppeteer from 'puppeteer';
 
describe('PWA Offline Behavior', () => {
  let browser, page;
 
  beforeAll(async () => {
    browser = await puppeteer.launch({ headless: false });
    page = await browser.newPage();
    await page.goto('https://localhost:3000');
    // Wait for service worker to activate
    await page.waitForFunction(() => navigator.serviceWorker.controller);
  });
 
  it('should load from cache when offline', async () => {
    await page.setOfflineMode(true);
    await page.reload();
 
    const title = await page.textContent('h1');
    expect(title).toBeTruthy();
  });
 
  it('should show offline fallback for uncached navigation', async () => {
    await page.setOfflineMode(true);
    await page.goto('https://localhost:3000/uncached-page');
 
    const fallback = await page.textContent('.offline-message');
    expect(fallback).toContain('offline');
  });
});

Future Outlook

The PWA ecosystem continues to evolve with new capabilities closing the gap with native applications. File System Access API enables PWAs to read and write local files. Web Bluetooth and Web USB open hardware access previously exclusive to native apps. WebGPU brings high-performance graphics and compute capabilities that were previously only available through WebGL or native APIs.

Apple's incremental improvements to Safari's PWA support — including push notifications in iOS 16.4, improved service worker caching, and better install prompts — signal a gradual convergence toward feature parity across all major platforms. The Project Fugu initiative at Google continues to ship new web platform capabilities that enable PWAs to access native features.

The Web Incubator Community Group (WICG) is working on several proposals that will further enhance PWAs, including advanced file handling, protocol handling, and window management APIs. As browser vendors continue to close the capability gap, the case for PWAs over native applications grows stronger for the majority of use cases.

Conclusion

Progressive Web Apps represent the most pragmatic approach to building modern applications that work everywhere. By leveraging service workers for offline support and caching, the web manifest for installability, and modern web APIs for push notifications and background sync, PWAs deliver experiences that rival native applications without the overhead of app store distribution and platform-specific development.

Key takeaways from this guide:

  1. The app shell pattern separates the static UI from dynamic content, enabling instant loading and reliable offline rendering.
  2. Cache strategies should match content types — use cache-first for static assets, network-first for API data, and stale-while-revalidate for content that changes occasionally.
  3. Background sync ensures that user actions taken offline are not lost, queuing requests until connectivity is restored.
  4. Push notifications re-engage users even when the app is not open, but must be requested at the right moment with clear value propositions.
  5. Workbox provides production-ready utilities for every aspect of service worker management, from precaching to runtime routing and expiration.

Start building your first PWA by creating a manifest, registering a service worker, and implementing a basic caching strategy. Use Lighthouse to verify compliance and iterate on your caching approach based on real usage patterns. The web.dev PWA documentation provides comprehensive guides, and the Workbox documentation covers production service worker tooling.