Claude Code for Progressive Web Apps: Service Workers, Offline, and Background Sync — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Progressive Web Apps: Service Workers, Offline, and Background Sync
Frontend

Claude Code for Progressive Web Apps: Service Workers, Offline, and Background Sync

Published: October 3, 2026
Read time: 8 min read
By: Claude Skills 360

Progressive web apps use service workers to intercept network requests, cache assets, and enable offline functionality. A service worker is a JavaScript file that runs in a separate thread with no DOM access — it acts as a network proxy between your app and the server. Claude Code implements caching strategies, background sync for deferred writes, push notifications, and the install prompt flow — the parts that make a PWA feel like a native app.

CLAUDE.md for PWA Projects

## PWA Stack
- Vite PWA plugin (vite-plugin-pwa) for service worker generation
- Workbox for caching strategy implementation
- Web Push with VAPID keys for notifications
- Background Sync API for deferred form submissions
- Cache names: app-shell-v1, api-cache-v1, image-cache-v1
- Cache-busting: version embedded in cache name, old caches deleted on activate
- Never cache: /api/auth/*, /api/user/* (personalized), payment endpoints

Service Worker Lifecycle

Implement the service worker with cache versioning.
Old caches should be cleaned up on activation.
// public/sw.ts (or sw.js) — compiled by Vite PWA plugin
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const CACHE_NAMES = {
  shell: 'app-shell-v3',    // Bump version to force update
  api: 'api-cache-v1',
  images: 'image-cache-v1',
};

const SHELL_ASSETS = [
  '/',
  '/offline.html',
  '/src/main.tsx',
  '/src/App.tsx',
  // Font preloads, critical CSS
];

// Install: cache the app shell immediately
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAMES.shell).then(cache => cache.addAll(SHELL_ASSETS))
    .then(() => self.skipWaiting())  // Activate immediately, don't wait for old tabs to close
  );
});

// Activate: delete old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => {
      const validCaches = new Set(Object.values(CACHE_NAMES));
      return Promise.all(
        keys
          .filter(key => !validCaches.has(key))
          .map(key => caches.delete(key))
      );
    }).then(() => self.clients.claim())  // Take control of all open tabs
  );
});

Caching Strategies

We need different caching strategies for:
- Static assets (JS/CSS): cache-first
- API responses (product list): stale-while-revalidate
- User data: network-first with offline fallback
// Fetch: route requests to the right caching strategy
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // Never cache: auth, payments, personalized data
  if (url.pathname.startsWith('/api/auth') || url.pathname.startsWith('/api/user')) {
    return;  // Falls through to normal network request
  }
  
  // Static assets: Cache-First
  // JS/CSS have content hashes in filenames — safe to cache forever
  if (isStaticAsset(url)) {
    event.respondWith(cacheFirst(event.request, CACHE_NAMES.shell));
    return;
  }
  
  // Product API: Stale-While-Revalidate
  // Show cached immediately, update in background
  if (url.pathname.startsWith('/api/products')) {
    event.respondWith(staleWhileRevalidate(event.request, CACHE_NAMES.api, 60 * 60));  // 1h TTL
    return;
  }
  
  // Images: Cache-First with 7-day TTL
  if (isImage(url)) {
    event.respondWith(cacheFirst(event.request, CACHE_NAMES.images, 7 * 24 * 60 * 60));
    return;
  }
  
  // HTML navigation: Network-First with offline fallback
  if (event.request.mode === 'navigate') {
    event.respondWith(networkFirstWithFallback(event.request));
    return;
  }
});

// Cache-First: return cached, fall back to network + cache
async function cacheFirst(request: Request, cacheName: string, maxAgeSeconds?: number): Promise<Response> {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  
  if (cached) {
    if (maxAgeSeconds) {
      const cachedTime = new Date(cached.headers.get('date') ?? 0).getTime();
      if (Date.now() - cachedTime < maxAgeSeconds * 1000) return cached;
    } else {
      return cached;
    }
  }
  
  const response = await fetch(request);
  if (response.ok) cache.put(request, response.clone());
  return response;
}

// Stale-While-Revalidate: return cached immediately, update in background
async function staleWhileRevalidate(request: Request, cacheName: string, maxAgeSeconds: number): Promise<Response> {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  
  // Revalidate in background regardless
  const fetchPromise = fetch(request).then(response => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });
  
  if (cached) {
    const cachedTime = new Date(cached.headers.get('date') ?? 0).getTime();
    const isStale = Date.now() - cachedTime > maxAgeSeconds * 1000;
    
    if (!isStale) return cached;
    // If stale, return cached but wait for fresh in background
    // Could notify the client here via postMessage
  }
  
  return fetchPromise;
}

// Network-First: try network, fall back to cache, then offline page
async function networkFirstWithFallback(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAMES.shell);
    cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;
    
    // Return offline page for navigation requests
    const offlinePage = await caches.match('/offline.html');
    return offlinePage ?? new Response('Offline', { status: 503 });
  }
}

function isStaticAsset(url: URL) {
  return /\.(js|css|woff2|ico)$/.test(url.pathname) || url.pathname.startsWith('/assets/');
}

function isImage(url: URL) {
  return /\.(png|jpg|jpeg|webp|avif|svg)$/.test(url.pathname);
}

Background Sync: Deferred Writes

Users fill out a form while offline. Submit when they reconnect.
// Register a sync when submitting while offline
async function submitOrder(orderData: Order) {
  try {
    // Try immediate submission
    const result = await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(orderData),
      headers: { 'Content-Type': 'application/json' },
    });
    return result.json();
  } catch {
    // Store for background sync
    await savePendingOrder(orderData);
    
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-orders');
    
    return { queued: true };
  }
}

// Store pending submissions in IndexedDB
async function savePendingOrder(order: Order) {
  const db = await openDB('app-store', 1, {
    upgrade(db) {
      db.createObjectStore('pending-orders', { keyPath: 'id' });
    },
  });
  
  await db.put('pending-orders', {
    ...order,
    id: crypto.randomUUID(),
    queuedAt: new Date().toISOString(),
  });
}

// In the service worker: handle sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-orders') {
    event.waitUntil(syncPendingOrders());
  }
});

async function syncPendingOrders() {
  const db = await openDB('app-store', 1);
  const pending = await db.getAll('pending-orders');
  
  for (const order of pending) {
    try {
      const response = await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify(order),
        headers: { 'Content-Type': 'application/json' },
      });
      
      if (response.ok) {
        await db.delete('pending-orders', order.id);
        // Notify the client tabs that the order synced
        const clients = await self.clients.matchAll();
        clients.forEach(client => {
          client.postMessage({ type: 'order-synced', orderId: order.id });
        });
      }
    } catch {
      // Will retry on next sync event
      throw new Error('Sync failed — will retry');
    }
  }
}

Push Notifications

Send push notifications when an order ships.
VAPID keys required.
// Generate VAPID keys once (store securely):
// npx web-push generate-vapid-keys

// server/push.ts
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

export async function sendShippingNotification(userId: string, orderId: string) {
  const subscription = await db('push_subscriptions').where('user_id', userId).first();
  if (!subscription) return;
  
  const payload = JSON.stringify({
    title: 'Your order shipped!',
    body: `Order #${orderId.slice(-6)} is on its way.`,
    url: `/orders/${orderId}`,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
  });
  
  try {
    await webpush.sendNotification(JSON.parse(subscription.endpoint_json), payload);
  } catch (err: any) {
    if (err.statusCode === 410) {
      // Subscription expired — remove it
      await db('push_subscriptions').where('user_id', userId).delete();
    }
  }
}

// Browser: subscribe to push
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,  // Required: every push must show a notification
    applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
  });
  
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' },
  });
}

// Service worker: handle push message
self.addEventListener('push', (event) => {
  const data = event.data?.json();
  
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      badge: data.badge,
      data: { url: data.url },
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    self.clients.openWindow(event.notification.data.url)
  );
});

Install Prompt

// Capture and re-show the browser install prompt
let deferredInstallPrompt: any = null;

window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault();
  deferredInstallPrompt = event;
  
  // Show your custom "Install App" button
  document.getElementById('install-btn')?.removeAttribute('hidden');
});

async function promptInstall() {
  if (!deferredInstallPrompt) return;
  
  deferredInstallPrompt.prompt();
  const { outcome } = await deferredInstallPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('PWA installed');
  }
  
  deferredInstallPrompt = null;
  document.getElementById('install-btn')?.setAttribute('hidden', '');
}

For the React component patterns used in the PWA UI, see the React guide. For Next.js SSR with PWA, the Next.js App Router guide covers the Workbox integration. The Claude Skills 360 bundle includes PWA skill sets covering caching strategies, background sync patterns, and push notification implementation. Start with the free tier to try service worker generation.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

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