| 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":["Codex","codex"]} |
PWA Offline-First
Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.
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.
- The task needs reusable judgment, domain constraints, or a proven workflow rather than ad hoc advice.
Do Not Use When
- The task is unrelated to
pwa-offline-first or would be better handled by a more specific companion skill.
- The request only needs a trivial answer and none of this skill's constraints or references materially help.
Required Inputs
- Gather relevant project context, constraints, and the concrete problem to solve; load
references only as needed.
- Confirm the desired deliverable: design, code, review, migration plan, audit, or documentation.
Workflow
- Read this
SKILL.md first, then load only the referenced deep-dive files that are necessary for the task.
- Apply the ordered guidance, checklists, and decision rules in this skill instead of cherry-picking isolated snippets.
- Produce the deliverable with assumptions, risks, and follow-up work made explicit when they matter.
Quality Standards
- Keep outputs execution-oriented, concise, and aligned with the repository's baseline engineering standards.
- Preserve compatibility with existing project conventions unless the skill explicitly requires a stronger standard.
- Prefer deterministic, reviewable steps over vague advice or tool-specific magic.
Anti-Patterns
- Treating examples as copy-paste truth without checking fit, constraints, or failure modes.
- Loading every reference file by default instead of using progressive disclosure.
Outputs
- A concrete result that fits the task: implementation guidance, review findings, architecture decisions, templates, or generated artifacts.
- Clear assumptions, tradeoffs, or unresolved gaps when the task cannot be completed from available context alone.
- References used, companion skills, or follow-up actions when they materially improve execution.
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.
Why Offline-First for East Africa
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
{
"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:
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;
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:
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.
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:
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 |
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.
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
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:
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.
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.
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.
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:
window.addEventListener('online', () => {
navigator.serviceWorker?.controller?.postMessage({ type: 'MANUAL_SYNC' });
});
Conflict Resolution
- Last-write-wins — trivial; acceptable for non-financial fields (notes, address corrections). Client timestamp overwrites server.
- Server-authoritative — server rejects stale writes with HTTP 409 + canonical record. Use for money, stock quantities, MoMo refs.
- Timestamp-based merge — each field carries an
updatedAt; newer value per-field wins. Use when two offline clients edit the same record.
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,
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.
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.
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;
}
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)