Use when building offline-first Progressive Web Apps — Service Worker lifecycle, Workbox caching strategies, IndexedDB via Dexie.js, Background Sync for queued writes, Web Push notifications, Lighthouse gates, and Next.js PWA integration. Default for apps that must work on EDGE/2G or intermittent connectivity.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add peterbamuhigire/chwezi-dev-engine --skill pwa-offline-first --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Pwa Offline First?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/peterbamuhigire-pwa-offline-first-chwezi-dev-engine)More formats (shields.io, HTML) on the badges page.
---
name: pwa-offline-first
description: Use when building offline-first Progressive Web Apps — Service Worker lifecycle, Workbox caching strategies, IndexedDB via Dexie.js, Background Sync for queued writes, Web Push notifications, Lighthouse gates, and Next.js PWA integration. Default for apps that must work on EDGE/2G or intermittent connectivity.
metadata:
portable: true
compatible_with:
- claude-code
- codex
---
# PWA Offline-First
Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.
<!-- dual-compat-start -->
## Use When
- Use when building offline-first Progressive Web Apps — Service Worker lifecycle, Workbox caching strategies, IndexedDB via Dexie.js, Background Sync for queued writes, Web Push notifications, Lighthouse gates, and Next.js PWA integration. Default for apps that must work on EDGE/2G or intermittent connectivity.
## References
- `references/tooling-and-tests.md` — full Vite + next-pwa configs, Lighthouse CI workflow, Playwright offline test scaffold.
- `references/safari-webkit-27-pwa-checks.md` for Safari/WebKit 27, iOS/iPadOS PWA limits, Customizable Select, `img sizes="auto"`, layout changes, HTML `<model>`, and web extension watch items.
<!-- dual-compat-end -->
## Why Offline-First for East Africa
## Inputs
| Artefact | Produced by | Required? | Why |
|---|---|---|---|
| API, error, and idempotency contract | `api-design-first` | required for queued writes | Defines safe replay and conflict handling |
| Data classification and retention rules | Security/product owners | required | Controls browser storage and caching |
| Offline critical-flow list | Product owner | required | Establishes what must work without connectivity |
## Decision Rules
| Request or asset | Strategy |
|---|---|
| Hashed immutable asset | Cache-first with bounded expiry |
| Navigation or refreshable read | Network-first with tested offline fallback |
| Mutation, authentication, or payment | Network-only transport; persist a separate idempotent outbox where allowed |
| Conflict involving money or stock | Server-authoritative resolution |
## Domain Anti-Patterns
- Caching POST responses. Fix: queue an idempotent mutation record instead.
- Calling `skipWaiting()` without an update UX. Fix: coordinate activation and reload safely.
- Treating `navigator.onLine` as server reachability. Fix: handle real request failures and captive portals.
- Storing tokens or sensitive payloads in Cache Storage. Fix: minimise storage and apply the data policy.
- Testing offline reads without replaying writes. Fix: verify save, restart, reconnect, deduplicate, and conflict paths.
Connectivity in Uganda, Kenya, and Tanzania is bimodal: urban fibre and 4G in Kampala CBD, Westlands, or Masaki, then EDGE/2G the moment a user boards a boda, enters a Bushenyi cooperative, or works inside a hospital ward with poor indoor coverage. Field workers counting inventory in Mbale, community health workers in Arua, and agents confirming MTN MoMo or Airtel Money disbursements in village kiosks all tolerate zero bars for minutes at a time. Co-working spaces on Kampala Road and Ngong Road suffer 30-second uplink stalls mid-upload. Every write path must queue locally and sync when connectivity returns; every read path must fall back to a cached response rather than a spinner.
## PWA Checklist
- Served over HTTPS (localhost exempt); Web App Manifest present and linked from `<head>`.
- Service Worker registered at the narrowest scope needed.
- Icons at 192x192, 512x512, and maskable variants.
- `beforeinstallprompt` captured and surfaced as an in-app install button.
- Lighthouse PWA score greater than or equal to 90.
- Core content renders with JavaScript disabled; interactive features degrade gracefully.
- `start_url` returns HTTP 200 when offline (served from cache).
## Web App Manifest
```json
{
"name": "Field Inventory Uganda",
"short_name": "FieldInv",
"id": "/",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#0f172a",
"background_color": "#ffffff",
"lang": "en-UG",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "New Visit", "url": "/visits/new" },
{ "name": "Pending Sync", "url": "/sync" }
]
}
```
Link from head: `<link rel="manifest" href="/manifest.webmanifest">`.
## App Install Criteria
Chromium browsers gate the install prompt on a fixed set of conditions (`web.dev/articles/install-criteria`):
- App is served over HTTPS.
- Manifest includes `short_name` or `name`.
- Icons include a 192px and a 512px icon.
- Manifest declares `start_url`.
- `display` is one of `fullscreen`, `standalone`, `minimal-ui`, or `window-controls-overlay`.
- `prefer_related_applications` is absent or `false`.
- User has clicked or tapped the page at least once and spent at least 30 seconds viewing it.
- The web app is not already installed.
Other browsers apply similar criteria with minor differences. Capture and defer the prompt so you can fire it from your own UI:
```javascript
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event;
showInstallButton();
});
document.querySelector('#install-app').addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice; // 'accepted' | 'dismissed'
analytics.track('pwa_install_prompt', { outcome });
deferredPrompt = null;
});
window.addEventListener('appinstalled', () => hideInstallButton());
```
## Service Worker Lifecycle
A Service Worker observes three lifecycle phases: Download, Install, Activate. Per MDN: "If this is the first time a service worker has been made available, installation is attempted, then after a successful installation, it is activated." The activate event "is generally a good time to clean up old caches and other things associated with the previous version." Functional events (`fetch`, `push`) wait on promises passed to `event.waitUntil()`. The browser checks for an updated worker on in-scope navigation, or on any worker event if it has not been downloaded in the last 24 hours.
Minimum registration:
```javascript
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' });
});
}
```
States: `installing` -> `installed (waiting)` -> `activating` -> `activated`. A new version waits until all old-worker tabs close, unless `skipWaiting()` is called.
```javascript
// sw.js
const CACHE_VERSION = 'v2025-11-01';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((cache) =>
cache.addAll(['/', '/offline.html', '/styles/app.css', '/scripts/app.js'])));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim()));
});
```
Client-side update prompt:
```javascript
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.addEventListener('updatefound', () => {
const incoming = reg.installing;
incoming?.addEventListener('statechange', () => {
if (incoming.state === 'installed' && navigator.serviceWorker.controller)
showUpdateToast(() => incoming.postMessage({ type: 'SKIP_WAITING' }));
});
});
});
```
## Workbox Setup
`vite-plugin-pwa` for Vite, `next-pwa` for Next.js. Default to `generateSW`; switch to `injectManifest` only when custom Service Worker logic is required. Full Vite and Next.js configurations live in `references/tooling-and-tests.md`.
## Caching Strategies
| Strategy | Use Case | TTL |
|---|---|---|
| CacheOnly | Static, versioned assets that don't refresh until the SW updates | Until next deploy |
| NetworkOnly | Content requiring freshness (HTML, mutations) where offline is not a priority | Never cached |
| CacheFirst | Immutable assets — fonts, hashed JS/CSS, versioned images | 30 days |
| NetworkFirst | HTML or APIs needing the latest version online with offline fallback | 10 min |
| StaleWhileRevalidate | Occasionally-updated content (avatars, help docs) where speed beats freshness | 24 hours |
```javascript
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
registerRoute(({ url }) => url.pathname.startsWith('/api/profile'),
new NetworkFirst({ cacheName: 'profile', networkTimeoutSeconds: 10 }));
registerRoute(({ request }) => request.destination === 'font',
new CacheFirst({ cacheName: 'fonts',
plugins: [new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 30 })] }));
registerRoute(({ url }) => url.pathname.startsWith('/help'),
new StaleWhileRevalidate({ cacheName: 'help-docs' }));
registerRoute(({ url }) => url.pathname.startsWith('/api/payments'),
new NetworkOnly(), 'POST');
```
Never cache mutation verbs. Workbox caches POST responses only when asked; doing so is almost always a bug.
## Cache Invalidation
Bump the precache manifest revision on every deploy; for runtime caches, embed a build hash in the `cacheName` so a deploy invalidates everything in one step. Workbox precaching ships `cleanupOutdatedCaches()` which "Adds an activate event listener which will clean up incompatible precaches that were created by older versions of Workbox." Call it once from your service worker entry. Use `workbox-expiration`'s `ExpirationPlugin({ maxEntries, maxAgeSeconds })` on each runtime route to bound disk usage on low-end Android devices.
## App Shell Architecture
The shell is the minimal HTML, CSS, and JavaScript needed to render UI chrome. Precached on install; content is fetched at runtime and layered in.
```text
App Shell (cached) --- API content (network + cache fallback)
header, nav, skeleton /api/visits, /api/customers
critical CSS images, documents
app.js bootstrap falls back to IndexedDB when offline
```
```javascript
// sw.ts (injectManifest mode)
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { NavigationRoute, registerRoute } from 'workbox-routing';
precacheAndRoute(self.__WB_MANIFEST);
const handler = createHandlerBoundToURL('/index.html');
registerRoute(new NavigationRoute(handler, { denylist: [/^\/api\//] }));
```
## IndexedDB with Dexie.js
IndexedDB is "a low-level API for client-side storage of significant amounts of structured data, including files/blobs" — a "transactional database system" with asynchronous reads and writes scoped to read-only or readwrite transactions. Schema migrations run inside an `IDBVersionChangeEvent` fired on `IDBOpenDBRequest.onupgradeneeded`. Same-origin policy applies.
| Storage | Use For | Limit | Notes |
|---|---|---|---|
| IndexedDB | Large or structured data, blobs, indexed queries | Browser-managed quota (often hundreds of MB) | Asynchronous, transactional |
| localStorage | Tiny key-value pairs, feature flags, last-route | ~5 MB per origin | Synchronous, blocks main thread |
| Cache Storage | HTTP request/response pairs (Workbox runtime caches) | Browser-managed | Owned by Service Worker |
Raw IndexedDB is verbose and transaction-leaky. Dexie wraps it with promises, typed tables, and migrations. Install with `npm install dexie`. Minimum example:
```javascript
import { Dexie } from 'dexie';
const db = new Dexie('MyDatabase');
db.version(1).stores({ friends: '++id, name, age' });
```
For schema evolution declare additional `db.version(N).stores(...)` blocks; Dexie auto-runs the diff.
```typescript
// src/db/index.ts
import Dexie, { Table } from 'dexie';
type SyncStatus = 'synced' | 'pending' | 'error';
export interface Customer { id?: number; externalId: string; name: string; phone: string; district: string; syncStatus: SyncStatus; updatedAt: number; }
export interface Visit { id?: number; customerId: number; notes: string; amountUgx: number; capturedAt: number; syncStatus: SyncStatus; }
export interface PendingSync { id?: number; endpoint: string; method: 'POST' | 'PUT' | 'DELETE'; body: string; attemptCount: number; createdAt: number; lastError?: string; }
export class FieldDB extends Dexie {
customers!: Table<Customer, number>;
visits!: Table<Visit, number>;
pendingSyncs!: Table<PendingSync, number>;
constructor() {
super('field-inventory-db');
this.version(1).stores({
customers: '++id, externalId, district, syncStatus, [district+syncStatus]',
visits: '++id, customerId, capturedAt, syncStatus',
pendingSyncs: '++id, endpoint, createdAt'
});
}
}
export const db = new FieldDB();
export const queuePendingCustomers = () => db.customers.where('syncStatus').equals('pending').toArray();
export const markCustomerSynced = (id: number) => db.customers.update(id, { syncStatus: 'synced' });
```
Compound indexes (`[district+syncStatus]`) let you query "all pending customers in Bushenyi" without a full-table scan.
## Offline Form Submissions
Write to IndexedDB first. Network is a best-effort add-on.
```typescript
import { db } from './db';
export async function saveVisit(input: Omit<Visit, 'id' | 'syncStatus'>) {
const id = await db.visits.add({ ...input, syncStatus: 'pending' });
await db.pendingSyncs.add({
endpoint: '/api/visits', method: 'POST',
body: JSON.stringify({ ...input, localId: id }),
attemptCount: 0, createdAt: Date.now()
});
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) await (reg as any).sync.register('sync-pending');
return id;
}
```
The user sees "Saved" immediately; the Service Worker drains the queue on reconnect.
## Background Sync API
Feature-detect first; fall back to an `online` listener on platforms without Background Sync, including Safari/WebKit configurations where Background Sync is unavailable or constrained.
```javascript
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-pending') event.waitUntil(syncPendingData());
});
async function syncPendingData() {
const db = await openDB();
for (const row of await db.getAll('pendingSyncs')) {
try {
const res = await fetch(row.endpoint, {
method: row.method,
headers: { 'Content-Type': 'application/json' },
body: row.body
});
if (res.ok) await db.delete('pendingSyncs', row.id);
else if (res.status === 409) await handleConflict(db, row, await res.json());
else await db.put('pendingSyncs', { ...row, attemptCount: row.attemptCount + 1, lastError: `${res.status}` });
} catch (err) {
await db.put('pendingSyncs', { ...row, attemptCount: row.attemptCount + 1, lastError: String(err) });
throw err;
}
}
}
```
Fallback for Safari:
```javascript
window.addEventListener('online', () => {
navigator.serviceWorker?.controller?.postMessage({ type: 'MANUAL_SYNC' });
});
```
## Conflict Resolution
1. **Last-write-wins** — trivial; acceptable for non-financial fields (notes, address corrections). Client timestamp overwrites server.
2. **Server-authoritative** — server rejects stale writes with HTTP 409 + canonical record. Use for money, stock quantities, MoMo refs.
3. **Timestamp-based merge** — each field carries an `updatedAt`; newer value per-field wins. Use when two offline clients edit the same record.
```typescript
async function handleConflict(db: IDBDatabase, row: PendingSync, server: any) {
const local = JSON.parse(row.body);
const merged = {
...server,
notes: local.updatedAt > server.updatedAt ? local.notes : server.notes,
amountUgx: server.amountUgx, // money: server wins
updatedAt: Date.now()
};
await fetch(row.endpoint, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': server.etag },
body: JSON.stringify(merged)
});
}
```
Money defaults to server-authoritative: the ledger is the single source of truth.
## Offline-First UX Patterns
- Connection-aware UI: read `navigator.onLine` and listen for `online`/`offline` events to toggle a banner. For coarse bandwidth detection inspect `navigator.connection.effectiveType` (`'2g'`, `'3g'`, `'4g'`); fall back gracefully where the Network Information API is unavailable.
- Optimistic updates: write to IndexedDB and reflect in the UI immediately; queue the network sync via Background Sync; mark the row `pending` until the SW confirms server ack.
- Sync indicator: a single cross-app component showing `idle | syncing | offline | conflict`, driven by Background Sync events plus an outbox-count selector on the Dexie `pendingSyncs` table.
- Save-Data awareness: respect the `Save-Data: on` request header by skipping autoplay video, deferring large image prefetch, and trimming list pages.
- Treat any non-2xx response from a captive portal or flaky DNS as a retryable sync failure, not a hard error.
```javascript
window.addEventListener('online', () => updateBanner('online'));
window.addEventListener('offline', () => updateBanner('offline'));
db.pendingSyncs.count().then((n) => setSyncBadge(n));
```
## Push Notifications
Generate a VAPID keypair once per environment (`npx web-push generate-vapid-keys`) and store the private key on the server.
```typescript
// Client
export async function subscribePush(vapidPublicKey: string) {
if ((await Notification.requestPermission()) !== 'granted') return null;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
await fetch('/api/push/subscribe', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
return subscription;
}
```
```javascript
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Update', body: '' };
event.waitUntil(self.registration.showNotification(data.title, {
body: data.body, icon: '/icons/icon-192.png', badge: '/icons/badge-72.png',
data: { url: data.url ?? '/' }
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.url));
});
```
## Testing and CI
Run Lighthouse CI on every PR (`@lhci/cli` against the static build) and a Playwright offline test that asserts the "save offline then sync on reconnect" round-trip. Workflow YAML and Playwright scaffold are in `references/tooling-and-tests.md`. Payment and authentication routes must be `NetworkOnly`; a cached 200 on `/api/payments/confirm` is a double-spend waiting to happen.
For iOS/iPadOS/macOS web surfaces, add Safari/WebKit 27 checks from `references/safari-webkit-27-pwa-checks.md` before treating a PWA as ready for Apple users.
## Performance Budget
- Time to Interactive less than or equal to 3 s on Slow 3G (400 Kbps, 400 ms RTT).
- First Contentful Paint less than or equal to 1.8 s on Slow 3G; offline cache load less than or equal to 1 s.
- Precache manifest less than or equal to 1 MB; critical-path JS less than or equal to 170 KB compressed.
- Largest Contentful Paint less than or equal to 2.5 s at P75 (Core Web Vitals "Good").
- Cumulative Layout Shift less than 0.1; Interaction to Next Paint less than 200 ms.
Enforce via Lighthouse CI assertions; a red budget fails the build rather than warning.
## Companion Skills
- `nextjs-app-router` — Next.js App Router patterns, layouts, server/client components, route handlers used as the offline-aware API surface.
- `react-development` — component patterns and state for the optimistic-update / sync-indicator UI.
- `frontend-performance` — Core Web Vitals, bundle budget, render-path analysis; do not duplicate budgets here.
- `image-compression` — client-side image compression before offline upload queueing.
## Sources
- Workbox — `developer.chrome.com/docs/workbox`; Dexie.js — `dexie.org/docs`
- MDN Service Worker API — `developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API`
- web.dev PWA — `web.dev/progressive-web-apps`; Lighthouse CI — `github.com/GoogleChrome/lighthouse-ci`
- *Building Progressive Web Apps* — Tal Ater (O'Reilly)
## Inputs
| Artefact | Required? | Purpose |
|---|---|---|
| Critical offline flows, data consistency, cache policy, sync conflicts, and install target | yes | Design offline behaviour |
## Outputs
- Produce PWA offline architecture or implementation with cache, sync, update, recovery, and test evidence.
## Degraded mode
Fallback without browser/network simulation: provide cache and sync tests but mark offline recovery unverified.
## Capability contract
Service-worker registration and cache tests may run locally; production cache invalidation and deployment require explicit release authority.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!