pwa-patterns

SKILL.md

PWA Patterns

Build Progressive Web Apps with service workers, Workbox caching strategies, offline-first data access, and native-like install experiences.

When to Use This Skill

  • Adding PWA capabilities to a Vite + React application
  • Configuring service workers with Workbox
  • Implementing offline-first data strategies
  • Setting up the web app manifest
  • Handling install prompts and app updates
  • Choosing cache strategies for different resource types

Vite PWA Plugin Setup

Installation

pnpm add -D vite-plugin-pwa

Vite Configuration

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: "autoUpdate",
      includeAssets: ["favicon.ico", "apple-touch-icon.png", "masked-icon.svg"],
      manifest: {
        name: "VeriFactu - Facturación Electrónica",
        short_name: "VeriFactu",
        description: "Portal de facturación electrónica para autónomos",
        theme_color: "#1e40af",
        background_color: "#ffffff",
        display: "standalone",
        orientation: "portrait-primary",
        scope: "/",
        start_url: "/",
        icons: [
          {
            src: "pwa-192x192.png",
            sizes: "192x192",
            type: "image/png",
          },
          {
            src: "pwa-512x512.png",
            sizes: "512x512",
            type: "image/png",
          },
          {
            src: "pwa-512x512.png",
            sizes: "512x512",
            type: "image/png",
            purpose: "any maskable",
          },
        ],
        shortcuts: [
          {
            name: "Nueva Factura",
            short_name: "Factura",
            description: "Crear una nueva factura",
            url: "/invoices/new",
            icons: [{ src: "shortcut-invoice.png", sizes: "96x96" }],
          },
        ],
      },
      workbox: {
        // Precache all static assets
        globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
        // Runtime caching rules
        runtimeCaching: [
          {
            // API calls: NetworkFirst with cache fallback
            urlPattern: /^https:\/\/api\.easyfactu\.es\/v1\/.*/i,
            handler: "NetworkFirst",
            options: {
              cacheName: "api-cache",
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 60 * 24, // 24 hours
              },
              networkTimeoutSeconds: 10,
              cacheableResponse: {
                statuses: [0, 200],
              },
            },
          },
          {
            // Images: StaleWhileRevalidate
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
            handler: "StaleWhileRevalidate",
            options: {
              cacheName: "image-cache",
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
              },
            },
          },
          {
            // Google Fonts: CacheFirst
            urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
            handler: "CacheFirst",
            options: {
              cacheName: "google-fonts-cache",
              expiration: {
                maxEntries: 10,
                maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
              },
            },
          },
        ],
      },
    }),
  ],
});

Cache Strategy Reference

Strategy Selection Guide

Static assets (JS, CSS, fonts)    → CacheFirst (long TTL, versioned filenames)
API data (invoices, customers)    → NetworkFirst (fallback to cache when offline)
Images (logos, avatars)           → StaleWhileRevalidate (show cached, update in background)
Auth tokens                      → Never cache
HTML pages                       → NetworkFirst (always try fresh)

CacheFirst

Best for: static assets that change infrequently (fonts, icons, versioned JS/CSS).

Request → Check Cache → [Hit] → Return cached
                      → [Miss] → Fetch from network → Cache → Return

NetworkFirst

Best for: API responses where freshness matters but offline access is needed.

Request → Try Network → [Success] → Cache → Return
                      → [Fail/Timeout] → Check Cache → Return cached (or offline page)

StaleWhileRevalidate

Best for: resources where slightly stale data is acceptable (images, non-critical data).

Request → Return from cache immediately (if available)
        → Simultaneously fetch from network → Update cache

Offline-First Patterns

Offline Page Fallback

// sw.ts (custom service worker if needed)
import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";

// Precache all build assets
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// Offline fallback for navigation requests
const navigationHandler = new NetworkFirst({
  cacheName: "pages-cache",
  plugins: [
    {
      handlerDidError: async () => {
        return caches.match("/offline.html");
      },
    },
  ],
});

registerRoute(new NavigationRoute(navigationHandler));

Offline Data Viewing

import { useQuery } from "@tanstack/react-query";

// TanStack Query with offline support
export function useInvoices() {
  return useQuery({
    queryKey: ["invoices"],
    queryFn: fetchInvoices,
    // Keep data in cache for offline access
    gcTime: 1000 * 60 * 60 * 24, // 24 hours
    staleTime: 1000 * 60 * 5, // 5 minutes
    // Use cached data when offline
    networkMode: "offlineFirst",
  });
}

Queue Mutations for Sync

import { useMutation, useQueryClient, onlineManager } from "@tanstack/react-query";

// Optimistic mutation with offline queue
export function useCreateInvoice() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createInvoice,
    // Optimistic update
    onMutate: async (newInvoice) => {
      await queryClient.cancelQueries({ queryKey: ["invoices"] });
      const previous = queryClient.getQueryData(["invoices"]);

      queryClient.setQueryData(["invoices"], (old: Invoice[]) => [
        ...old,
        { ...newInvoice, id: `temp-${Date.now()}`, status: "pending_sync" },
      ]);

      return { previous };
    },
    onError: (_err, _vars, context) => {
      queryClient.setQueryData(["invoices"], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["invoices"] });
    },
    // Retry when back online
    retry: 3,
    retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
  });
}

// Detect online/offline status
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return isOnline;
}

Background Sync API

// Register a background sync tag when offline
async function queueInvoiceSubmission(invoice: InvoiceCreate) {
  if ("serviceWorker" in navigator && "SyncManager" in window) {
    // Store the pending mutation in IndexedDB
    await saveToIndexedDB("pending-invoices", invoice);

    // Register background sync
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register("sync-invoices");
  } else {
    // Fallback: try immediately
    await createInvoice(invoice);
  }
}

// In service worker: handle sync event
self.addEventListener("sync", (event) => {
  if (event.tag === "sync-invoices") {
    event.waitUntil(syncPendingInvoices());
  }
});

async function syncPendingInvoices() {
  const pending = await getFromIndexedDB("pending-invoices");
  for (const invoice of pending) {
    try {
      await fetch("/v1/invoices", {
        method: "POST",
        body: JSON.stringify(invoice),
        headers: { "Content-Type": "application/json" },
      });
      await removeFromIndexedDB("pending-invoices", invoice.id);
    } catch {
      // Will retry on next sync
      break;
    }
  }
}

Install Experience

Install Prompt Handling

import { useState, useEffect, useCallback } from "react";

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}

export function useInstallPrompt() {
  const [installPrompt, setInstallPrompt] =
    useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // Check if already installed
    if (window.matchMedia("(display-mode: standalone)").matches) {
      setIsInstalled(true);
      return;
    }

    const handler = (e: Event) => {
      e.preventDefault();
      setInstallPrompt(e as BeforeInstallPromptEvent);
    };

    window.addEventListener("beforeinstallprompt", handler);

    // Detect successful installation
    window.addEventListener("appinstalled", () => {
      setIsInstalled(true);
      setInstallPrompt(null);
    });

    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);

  const promptInstall = useCallback(async () => {
    if (!installPrompt) return false;

    await installPrompt.prompt();
    const { outcome } = await installPrompt.userChoice;

    if (outcome === "accepted") {
      setInstallPrompt(null);
    }

    return outcome === "accepted";
  }, [installPrompt]);

  return {
    canInstall: !!installPrompt && !isInstalled,
    isInstalled,
    promptInstall,
  };
}

Custom Install Banner

export function InstallBanner() {
  const { canInstall, promptInstall } = useInstallPrompt();
  const [dismissed, setDismissed] = useState(false);

  if (!canInstall || dismissed) return null;

  return (
    <div className="fixed bottom-4 left-4 right-4 z-50 rounded-lg border bg-white p-4 shadow-lg md:left-auto md:w-96">
      <div className="flex items-start gap-3">
        <img src="/pwa-192x192.png" alt="" className="h-12 w-12 rounded-lg" />
        <div className="flex-1">
          <h3 className="font-semibold">Instalar VeriFactu</h3>
          <p className="text-sm text-muted-foreground">
            Accede más rápido y trabaja sin conexión
          </p>
        </div>
      </div>
      <div className="mt-3 flex gap-2">
        <button
          onClick={promptInstall}
          className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white"
        >
          Instalar
        </button>
        <button
          onClick={() => setDismissed(true)}
          className="rounded-md px-4 py-2 text-sm text-muted-foreground"
        >
          Ahora no
        </button>
      </div>
    </div>
  );
}

Update Notification

import { useRegisterSW } from "virtual:pwa-register/react";

export function UpdateNotification() {
  const {
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker,
  } = useRegisterSW({
    onRegisteredSW(swUrl, r) {
      // Check for updates every hour
      r && setInterval(() => r.update(), 60 * 60 * 1000);
    },
  });

  if (!needRefresh) return null;

  return (
    <div className="fixed bottom-4 right-4 z-50 rounded-lg border bg-white p-4 shadow-lg">
      <p className="text-sm font-medium">Nueva versión disponible</p>
      <div className="mt-2 flex gap-2">
        <button
          onClick={() => updateServiceWorker(true)}
          className="rounded bg-primary px-3 py-1 text-sm text-white"
        >
          Actualizar
        </button>
        <button
          onClick={() => setNeedRefresh(false)}
          className="rounded px-3 py-1 text-sm text-muted-foreground"
        >
          Después
        </button>
      </div>
    </div>
  );
}

Web App Manifest

Full Manifest Example

{
  "name": "VeriFactu - Facturación Electrónica",
  "short_name": "VeriFactu",
  "description": "Portal de facturación electrónica para autónomos y PYMEs",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#1e40af",
  "background_color": "#ffffff",
  "lang": "es",
  "dir": "ltr",
  "categories": ["business", "finance"],
  "icons": [
    { "src": "/pwa-64x64.png", "sizes": "64x64", "type": "image/png" },
    { "src": "/pwa-192x192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/pwa-512x512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
  ],
  "screenshots": [
    { "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
    { "src": "/screenshots/mobile.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" }
  ],
  "shortcuts": [
    {
      "name": "Nueva Factura",
      "short_name": "Factura",
      "url": "/invoices/new",
      "icons": [{ "src": "/shortcut-invoice.png", "sizes": "96x96" }]
    },
    {
      "name": "Clientes",
      "url": "/customers",
      "icons": [{ "src": "/shortcut-customers.png", "sizes": "96x96" }]
    }
  ]
}

Testing

Lighthouse PWA Audit

# Run Lighthouse CI
npx lighthouse http://localhost:5173 --output=json --output-path=./lighthouse.json

# Check PWA score
npx lighthouse http://localhost:5173 --only-categories=pwa

Service Worker Debugging

// In browser DevTools > Application > Service Workers:
// - Check registration status
// - Force update
// - Test offline mode

// Programmatic check
if ("serviceWorker" in navigator) {
  const registration = await navigator.serviceWorker.getRegistration();
  console.log("SW registered:", registration?.active?.state);
}

Offline Simulation

# Chrome DevTools > Network > Offline checkbox
# Or programmatically in tests:
// In Playwright E2E tests
test("shows offline data when network is down", async ({ page, context }) => {
  // Load page online first (to populate cache)
  await page.goto("/invoices");
  await page.waitForSelector("[data-testid=invoice-list]");

  // Go offline
  await context.setOffline(true);

  // Reload — should show cached data
  await page.reload();
  await expect(page.locator("[data-testid=invoice-list]")).toBeVisible();
  await expect(page.locator("[data-testid=offline-badge]")).toBeVisible();

  // Go back online
  await context.setOffline(false);
});

Guidelines

  • Use vite-plugin-pwa for Workbox integration with Vite
  • Apply NetworkFirst for API data, CacheFirst for static assets
  • Never cache auth tokens or sensitive data
  • Use TanStack Query with networkMode: "offlineFirst" for offline data
  • Implement optimistic updates for mutations when offline
  • Show clear offline indicators to users
  • Handle app updates with user-friendly notifications
  • Test with Lighthouse PWA audit for compliance
  • Use Background Sync for queuing mutations when offline
  • Keep manifest complete with icons, shortcuts, and screenshots
Weekly Installs
1
First Seen
12 days ago
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1