Skip to content
Codeloom
Web

Building a Progressive Web App from Scratch

Step-by-step guide to building a PWA: web app manifest, service worker registration, caching strategies, offline support, push notifications, and installability.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How to create a web app manifest for installability
  • How to write a service worker with caching strategies
  • How to implement offline support with a fallback page
  • How to handle the install prompt and update flow
  • How to add push notifications with the Push API

Prerequisites

  • JavaScript and Fetch API basics
  • HTTPS-enabled development environment

The Three Pillars

A PWA has three requirements: a web app manifest, a service worker, and HTTPS. Get those three right and the browser will offer to install your site as an app.

Step 1: The Web App Manifest

{
  "name": "TaskFlow - Project Manager",
  "short_name": "TaskFlow",
  "description": "Lightweight project management in your browser",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0f172a",
  "theme_color": "#3b82f6",
  "orientation": "any",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "TaskFlow desktop view"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "TaskFlow mobile view"
    }
  ],
  "shortcuts": [
    {
      "name": "New Task",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/new-task.png", "sizes": "96x96" }]
    },
    {
      "name": "Dashboard",
      "url": "/dashboard",
      "icons": [{ "src": "/icons/dashboard.png", "sizes": "96x96" }]
    }
  ]
}

Link it in your HTML:

<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#3b82f6" />
<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-192.png" />

Key fields:

  • display: "standalone" makes it look like a native app (no browser chrome)
  • scope limits which URLs the PWA controls
  • purpose: "maskable" provides icons that work with adaptive icon shapes on Android
  • screenshots enables the richer install dialog on supported browsers

Step 2: The Service Worker

Registration

// src/register-sw.ts
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });
      console.log('SW registered:', registration.scope);

      // Check for updates periodically
      setInterval(() => {
        registration.update();
      }, 60 * 60 * 1000); // Every hour
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

The Service Worker File

// public/sw.js
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;

// Files to cache immediately on install
const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/icons/icon-192.png',
];

// Install: cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => {
      console.log('Caching static assets');
      return cache.addAll(STATIC_ASSETS);
    }),
  );
  // Activate immediately instead of waiting
  self.skipWaiting();
});

// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
          .map((key) => {
            console.log('Deleting old cache:', key);
            return caches.delete(key);
          }),
      );
    }),
  );
  // Take control of all clients immediately
  self.clients.claim();
});

// Fetch: apply caching strategies
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Skip non-GET requests
  if (request.method !== 'GET') return;

  // Skip cross-origin requests
  if (url.origin !== self.location.origin) return;

  // API calls: network first, fall back to cache
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Static assets: cache first, fall back to network
  if (url.pathname.startsWith('/_astro/') || url.pathname.match(/\.(js|css|png|jpg|svg|woff2)$/)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // HTML pages: stale-while-revalidate
  event.respondWith(staleWhileRevalidate(request));
});

Caching Strategies

// Cache first: fast, good for static assets
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(STATIC_CACHE);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    return new Response('Offline', { status: 503 });
  }
}

// Network first: fresh data, falls back to cache
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(DYNAMIC_CACHE);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached || new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

// Stale-while-revalidate: serve cache, update in background
async function staleWhileRevalidate(request) {
  const cached = await caches.match(request);

  const fetchPromise = fetch(request)
    .then((response) => {
      if (response.ok) {
        caches.open(DYNAMIC_CACHE).then((cache) => {
          cache.put(request, response.clone());
        });
      }
      return response;
    })
    .catch(() => null);

  // Return cached version immediately, or wait for network
  if (cached) {
    // Fire off background update
    fetchPromise;
    return cached;
  }

  // No cache, must wait for network
  const response = await fetchPromise;
  if (response) return response;

  // Complete offline fallback
  return caches.match('/offline.html');
}

Step 3: Offline Fallback Page

<!-- public/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 - TaskFlow</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #0f172a;
      color: #e2e8f0;
      text-align: center;
    }
    .container { max-width: 400px; padding: 2rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    p { color: #94a3b8; }
    button {
      margin-top: 1rem;
      padding: 0.75rem 1.5rem;
      background: #3b82f6;
      color: white;
      border: none;
      border-radius: 0.5rem;
      cursor: pointer;
      font-size: 1rem;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>You are offline</h1>
    <p>Check your internet connection and try again.</p>
    <button onclick="window.location.reload()">Retry</button>
  </div>
</body>
</html>

Step 4: The Install Prompt

Browsers show an install prompt automatically, but you can capture it and trigger it at a better time:

// src/install-prompt.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent the default browser prompt
  event.preventDefault();
  deferredPrompt = event as BeforeInstallPromptEvent;

  // Show your custom install button
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'block';
    installButton.addEventListener('click', async () => {
      if (!deferredPrompt) return;

      deferredPrompt.prompt();
      const { outcome } = await deferredPrompt.userChoice;
      console.log('Install prompt outcome:', outcome);
      deferredPrompt = null;
      installButton.style.display = 'none';
    });
  }
});

// Detect if the app is already installed
window.addEventListener('appinstalled', () => {
  console.log('App installed');
  deferredPrompt = null;
  const installButton = document.getElementById('install-button');
  if (installButton) installButton.style.display = 'none';
});

// Check if running in standalone mode (already installed)
if (window.matchMedia('(display-mode: standalone)').matches) {
  console.log('Running as installed PWA');
}

Step 5: Service Worker Updates

When you deploy a new version, the browser detects the changed sw.js and triggers an update:

// In your registration code
navigator.serviceWorker.register('/sw.js').then((registration) => {
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    if (!newWorker) return;

    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
        // New version available — prompt the user
        showUpdateBanner();
      }
    });
  });
});

function showUpdateBanner() {
  const banner = document.createElement('div');
  banner.innerHTML = `
    <div style="position:fixed;bottom:0;left:0;right:0;padding:1rem;
      background:#1e40af;color:white;text-align:center;z-index:9999;">
      A new version is available.
      <button onclick="window.location.reload()" style="margin-left:1rem;
        padding:0.25rem 0.75rem;background:white;color:#1e40af;border:none;
        border-radius:0.25rem;cursor:pointer;">Update</button>
    </div>
  `;
  document.body.appendChild(banner);
}

Step 6: Push Notifications

Request Permission

async function requestNotificationPermission(): Promise<boolean> {
  if (!('Notification' in window)) return false;

  if (Notification.permission === 'granted') return true;
  if (Notification.permission === 'denied') return false;

  const result = await Notification.requestPermission();
  return result === 'granted';
}

Subscribe to Push

async function subscribeToPush(): Promise<PushSubscription | null> {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(
      import.meta.env.PUBLIC_VAPID_KEY,
    ),
  });

  // Send subscription to your server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Handle Push in Service Worker

// In sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {
    title: 'Notification',
    body: 'You have a new notification',
  };

  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: 'dismiss', title: 'Dismiss' },
      ],
    }),
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

  const url = event.notification.data?.url ?? '/';

  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clients) => {
      // Focus existing window if available
      for (const client of clients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // Otherwise open new window
      return self.clients.openWindow(url);
    }),
  );
});

Testing Your PWA

  1. Chrome DevTools > Application tab: Check manifest, service worker status, and cache storage
  2. Lighthouse: Run a PWA audit to check all requirements
  3. Offline testing: In DevTools Network tab, check “Offline” and navigate your site
  4. Install testing: On Android, Chrome shows install prompts automatically. On desktop, look for the install icon in the address bar

Summary

Building a PWA is a series of concrete steps: create a manifest for installability, register a service worker for offline support, implement caching strategies that match your data freshness needs, handle the install prompt gracefully, and add push notifications for re-engagement. Each piece works independently, so you can add them incrementally. Start with the manifest and a basic cache-first service worker, then layer on the advanced features.