Claude Code for Progressive Web Apps: Offline, Push Notifications, and Install — Claude Skills 360 Blog
Blog / Development / Claude Code for Progressive Web Apps: Offline, Push Notifications, and Install
Development

Claude Code for Progressive Web Apps: Offline, Push Notifications, and Install

Published: June 24, 2026
Read time: 9 min read
By: Claude Skills 360

Progressive Web Apps close the gap between web and native: offline capability, push notifications, home screen installation, and background sync. The service worker API makes this possible but is notoriously error-prone. Claude Code generates service worker code with correct cache invalidation, proper scope handling, and the update lifecycle that most PWA implementations get wrong.

This guide covers building production PWAs with Claude Code: service worker strategies, push notifications, background sync, and the install experience.

Web App Manifest

Make our web app installable on desktop and mobile.
Include proper icons and splash screen configuration.
// public/manifest.json
{
  "name": "MyApp — Task Management",
  "short_name": "MyApp",
  "description": "Manage tasks and projects, even offline.",
  "start_url": "/app",
  "scope": "/",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#ffffff",
  "theme_color": "#0ea5e9",
  "lang": "en",
  "categories": ["productivity", "utilities"],
  
  "icons": [
    { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
    { "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
    { "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
    { "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
    { "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
    { "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ],
  
  "shortcuts": [
    {
      "name": "New Task",
      "short_name": "New Task",
      "description": "Create a new task",
      "url": "/app/tasks/new",
      "icons": [{ "src": "/icons/shortcut-task.png", "sizes": "96x96" }]
    }
  ],
  
  "screenshots": [
    {
      "src": "/screenshots/desktop-dashboard.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Dashboard on desktop"
    },
    {
      "src": "/screenshots/mobile-tasks.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Task list on mobile"
    }
  ]
}
<!-- In <head> -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#0ea5e9" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />

Service Worker

Add offline support to the app.
Cache the app shell on install, fetch from cache first for static assets,
and network-first for API calls with offline fallback.
// public/sw.ts (using Workbox via CDN or npm)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';

declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: object[] };

// Precache: static assets injected by the build tool (Vite, webpack)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// Strategy 1: Cache-first for static assets (fonts, images, CSS)
registerRoute(
  ({ request }) => ['font', 'image', 'style'].includes(request.destination),
  new CacheFirst({
    cacheName: 'static-assets-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  }),
);

// Strategy 2: Stale-while-revalidate for read-only API data
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/') && url.pathname.includes('/read'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache-v1',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 }), // 1 hour
    ],
  }),
);

// Strategy 3: Network-first for mutation APIs
const bgSyncPlugin = new BackgroundSyncPlugin('mutations-queue', {
  maxRetentionTime: 24 * 60, // Retry for up to 24 hours (minutes)
});

registerRoute(
  ({ url, request }) =>
    url.pathname.startsWith('/api/') &&
    ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method),
  new NetworkFirst({
    cacheName: 'api-mutations-v1',
    networkTimeoutSeconds: 3,
    plugins: [bgSyncPlugin], // Queue mutations if offline
  }),
  'POST'
);

// Strategy 4: App shell — network-first with offline fallback page
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages-v1',
    plugins: [
      {
        handlerDidError: async () => {
          return caches.match('/offline.html');
        },
      },
    ],
  }),
);

// Handle service worker updates
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Service Worker Registration with Update Handling

// src/service-worker-registration.ts
export async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) return;
  
  try {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
    });
    
    // Handle updates
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      if (!newWorker) return;
      
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // New version available — show update prompt
          showUpdatePrompt(() => {
            newWorker.postMessage({ type: 'SKIP_WAITING' });
            window.location.reload();
          });
        }
      });
    });
    
    // Listen for controller change (happens after skipWaiting)
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      window.location.reload();
    });
    
    console.log('Service worker registered');
  } catch (error) {
    console.error('Service worker registration failed:', error);
  }
}

function showUpdatePrompt(onUpdate: () => void) {
  // Show a toast/banner: "New version available" with "Update" button
  const event = new CustomEvent('sw-update-available', {
    detail: { onUpdate },
  });
  window.dispatchEvent(event);
}

Push Notifications

Add push notifications to notify users when
a task is assigned to them.
// src/push-notifications.ts
export async function requestPushPermission(): Promise<PushSubscription | null> {
  if (!('Notification' in window) || !('PushManager' in window)) {
    console.log('Push not supported');
    return null;
  }
  
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;
  
  const registration = await navigator.serviceWorker.ready;
  
  // Subscribe — VAPID public key from your push server
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(
      import.meta.env.VITE_VAPID_PUBLIC_KEY
    ),
  });
  
  // Send subscription to your server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
  
  return subscription;
}

// Service worker push handler (in sw.ts)
self.addEventListener('push', (event) => {
  if (!event.data) return;
  
  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: 'dismiss', title: 'Dismiss' },
      ],
      tag: data.tag ?? 'default',
      requireInteraction: data.priority === 'high',
    })
  );
});

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 open
      const existingClient = clients.find(c => c.url === url);
      if (existingClient) return existingClient.focus();
      
      // Otherwise open new window
      return self.clients.openWindow(url);
    })
  );
});

Install Prompt

Show our own "Install app" button instead of the browser's
mini-infobar. Only show it after the user has used the app
a few times.
// src/pwa-install.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;

// Capture the install prompt before it fires automatically
window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault(); // Prevent automatic prompt
  deferredPrompt = event as BeforeInstallPromptEvent;
  
  // Show your custom install button/banner
  window.dispatchEvent(new CustomEvent('pwa-install-available'));
});

window.addEventListener('appinstalled', () => {
  deferredPrompt = null;
  // Track installation in analytics
  analytics.track('pwa_installed');
});

export async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
  if (!deferredPrompt) return 'unavailable';
  
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  deferredPrompt = null;
  
  analytics.track('pwa_install_prompt', { outcome });
  return outcome;
}

export function isInstalled(): boolean {
  return window.matchMedia('(display-mode: standalone)').matches ||
    (window.navigator as any).standalone === true;
}

For testing PWA behavior including offline mode and service worker updates, the Playwright E2E guide covers Playwright’s PWA testing APIs. For deploying PWAs to Cloudflare Pages with proper caching headers, the serverless guide covers the _headers configuration. The Claude Skills 360 bundle includes PWA skill sets for service worker strategies and push notification integration. Start with the free tier to generate a service worker for your existing web app.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free