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
Repository
franciscosanche…factu-esFirst Seen
12 days ago
Security Audits
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1