PWA and Service Worker
Detailed source:
docs/PWA_OFFLINE_ARCHITECTURE.md(exhaustive documentation with code excerpts).
Multi-cache architecture
The application uses three distinct caches with different strategies depending on the resource type:
| Cache | Current name | Strategy | Content |
|---|---|---|---|
| App shell | racines-cache-v11 |
Cache First / Network First | HTML, icons, chunks /_next/static/*.js |
| Data | racines-data-cache-v2 |
Network First | API responses, Next.js RSC payloads |
| Audio | racines-audio-cache-v3 |
Cache First | .mp3 files from MinIO CDN |
Version increment rule:
CACHE_NAME (app shell)
✓ Increment if: new resource in PRECACHE_URLS (renamed logo, new icon)
✗ DO NOT increment if: normal JS/CSS build (content-hashed → auto-miss)
✗ DO NOT increment if: SW logic modification (byte-to-byte → auto-detection)
AUDIO_CACHE
✓ Increment if: change in audio URL structure
DATA_CACHE
✓ Increment if: incompatible change in API response structure
Cache strategies by resource type
Audio: Cache First
// public/sw.js — detect audio files
if (request.url.includes(".mp3") || request.url.includes(".wav") ||
url.pathname.includes("/audio/") || url.pathname.includes("/audios/")) {
const cache = await caches.open(AUDIO_CACHE);
// ⚠️ ignoreVary: true mandatory
// The CDN sends Vary: Accept-Encoding — without ignoreVary, the match fails offline
const cachedResponse = await cache.match(request, { ignoreVary: true });
if (cachedResponse) return cachedResponse; // Cache hit → immediate return
// Cache miss → network fetch and store without the Vary header
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const body = await networkResponse.clone().arrayBuffer();
const cleanResponse = new Response(body, {
headers: Array.from(networkResponse.headers.entries())
.filter(([k]) => k.toLowerCase() !== "vary"), // Strip Vary
});
cache.put(request, cleanResponse);
}
return networkResponse;
}
Why strip Vary?
The MinIO CDN sends Vary: Accept-Encoding. The SW fetches without Accept-Encoding, but the browser fetches with it. Without stripping, cache.match() always returns a false negative → audio is never served from cache.
HTML navigation: Network First + SPA Fallback
if (request.mode === "navigate") {
try {
const response = await fetch(request);
// Update cache on every visit
if (response.ok) cache.put(request, stripVary(response.clone()));
return response;
} catch {
// Offline: look in cache
const exactMatch = await cache.match(request, { ignoreVary: true });
if (exactMatch) return exactMatch;
// Page never visited → serve the app shell "/"
// The Next.js client-side router handles display
const appShell = await cache.match("/", { ignoreVary: true });
if (appShell) return appShell;
// Last resort: static offline page
return offlinePage();
}
}
Next.js RSC: Network First + ignoreSearch
if (url.searchParams.has("_rsc")) {
try {
const response = await fetch(request);
if (response.ok) dataCache.put(request, response.clone());
return response;
} catch {
// ignoreSearch: true to tolerate the variable _rsc hash
// Each build generates a different hash (?_rsc=abc123 → ?_rsc=def456)
const cached = await dataCache.match(request, { ignoreSearch: true });
if (cached) return cached;
return new Response("", { status: 503 }); // Next.js falls back to static HTML
}
}
Next.js assets: Stale-While-Revalidate
if (request.url.includes("/_next/")) {
const cachedResponse = await caches.match(request, { ignoreVary: true });
// Return cache immediately + fetch in background
const fetchPromise = fetch(request).then(r => { if (r.ok) cache.put(request, r.clone()); return r; });
return cachedResponse || fetchPromise;
}
Installation and activation
Installation: Promise.allSettled() instead of addAll()
// public/sw.js
const PRECACHE_URLS = [
"/", "/manifest.json",
"/icons/logo-racines.png", "/icons/apple-icon.png",
"/icons/web-app-manifest-192x192.png", "/icons/web-app-manifest-512x512.png",
"/icons/defi-mot.svg", "/icons/defi-phrase.svg",
// ...
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
// Promise.allSettled() instead of addAll():
// → A 404 on a secondary icon does not block caching of "/"
const results = await Promise.allSettled(
PRECACHE_URLS.map((url) => cache.add(url))
);
return self.skipWaiting();
})
);
});
Activation: cleanup of old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(cacheNames.map((name) => {
if (
(name.startsWith("racines-cache-") && name !== CACHE_NAME) ||
(name.startsWith("racines-audio-cache-") && name !== AUDIO_CACHE) ||
(name.startsWith("racines-data-cache-") && name !== DATA_CACHE)
) return caches.delete(name);
}))
).then(() => self.clients.claim())
// claim(): takes immediate control without waiting for reload
);
});
Offline download (src/lib/offline-download.ts)
The 5 download steps
[0%] ── Connectivity check (isOnline())
[8%] ── Step 1: API → IndexedDB
GET /api/languages/[id]/content
→ saveToIndexedDB('languages', [language])
→ saveToIndexedDB('cards', cards)
→ saveToIndexedDB('items', items)
[12%–85%] ── Step 2: Audio download (parallel, semaphore MAX_CONCURRENT=3)
For each item with audio_url:
→ cacheAudio(getAudioUrl(item.audio_url))
→ Storage without Vary in AUDIO_CACHE
[85%–90%] ── Step 2b: Verification (scan + re-download of orphans)
→ Scans all audio in AUDIO_CACHE
→ Re-downloads missing ones (false positives)
[90%] ── Step 3: Orphan cleanup (if re-download)
→ Deletes cards/items from the old version not present in the new one
[90%–99%] ── Step 4: Pre-cache HTML pages + RSC via MessageChannel
Pre-cached pages:
/{code}, /{code}/words, /{code}/phrases, /{code}/revision,
/{code}/offline, /contact,
/{code}/revision/{slug} (per theme),
/{code}/words/{n} (all word cards),
/{code}/phrases/{n} (all phrase cards)
[100%] ── Step 5: localStorage marking
→ localStorage.setItem('downloaded-languages', JSON.stringify([...ids, languageId]))
⚠️ Marked LAST — guarantees the app is truly ready offline
Communication with the Service Worker (MessageChannel)
// Creating a bidirectional channel
const channel = new MessageChannel();
// Sophisticated timeouts
const INACTIVITY_MS = 30_000; // Reset after each heartbeat
const ABSOLUTE_MS = 120_000; // Absolute timeout (even with heartbeats)
channel.port1.onmessage = (event) => {
if (event.data?.done) {
// CACHE_PAGES completed normally
clearTimeout(inactivityTimer);
clearTimeout(absoluteTimer);
resolve();
} else {
// Heartbeat: one page processed → reset the sliding timeout
clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(resolve, INACTIVITY_MS);
// Update progress...
}
};
// Send the command to the SW with port2 transfer
controller.postMessage(
{ type: 'CACHE_PAGES', urls: urlsToCache },
[channel.port2]
);
IndexedDB — version 3
Database: racines-db
Current version: 3
Store schema
racines-db (v3)
├── languages (keyPath: id)
│ └── { id, code, name, primary_color, icon_url, ... }
│
├── cards (keyPath: id)
│ └── { id, language_id, card_number, type, theme, ... }
│
├── items (keyPath: id)
│ ├── Index: by_card_id → card_id (added in v3)
│ └── { id, card_id, audio_url, original_text, translation, ... }
│
└── statistics-queue (keyPath: id autoIncrement)
├── Index: timestamp
└── { id, timestamp, eventType, languageId, cardId, itemId }
↑ Queue for offline statistics synchronization
Migration v3 — adding the by_card_id index
// src/lib/offline.ts
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = event.oldVersion;
// v3: by_card_id index on the items store
const ensureItemsIndex = (store: IDBObjectStore) => {
if (!store.indexNames.contains("by_card_id")) {
store.createIndex("by_card_id", "card_id", { unique: false });
}
};
if (!db.objectStoreNames.contains("items")) {
ensureItemsIndex(db.createObjectStore("items", { keyPath: "id" }));
} else if (oldVersion < 3) {
// Migration from v1 or v2: add the index to the existing store
ensureItemsIndex(
(event.target as IDBOpenDBRequest).transaction!.objectStore("items")
);
}
};
Any addition of a store or index must increment the version and handle onupgradeneeded.
Reading items for a card (optimized)
// Uses the by_card_id index for an O(log n) query instead of O(n)
const index = store.index("by_card_id");
const req = index.getAll(IDBKeyRange.only(cardId));
Service Worker registration (ServiceWorkerRegistration.tsx)
The src/components/ServiceWorkerRegistration.tsx component handles:
- Offline statistics synchronization — on network reconnection, flushes the
statistics-queueIndexedDB queue - SW registration — only if
NEXT_PUBLIC_PWA_ENABLED === 'true' - Update detection — shows a banner when a new SW is waiting
// Resolved trap: in production, window.load has already fired before useEffect
if (document.readyState === "complete") {
registerSW(); // Fast path
} else {
window.addEventListener("load", registerSW); // Slow path
}
Update:
const handleUpdate = () => {
waitingWorker?.postMessage("skipWaiting"); // Activate the new SW
window.location.reload(); // Reload to use the new cache
};
The SW is disabled on /admin and /speaker (risk of cache conflicts on these stateful interfaces).
Network circuit breaker (src/lib/network-state.ts)
navigator.onLine alone is unreliable (WiFi connected but internet down → returns true). The circuit breaker adds real detection logic.
States:
CLOSED ──3 failures in 60s──▶ OPEN ──probe succeeded──▶ HALF_OPEN ──1 real success──▶ CLOSED
▲ │
└──────────────────────────────────────────────────────────────────────────────
OPEN: Triggered after 3 network failures within a 60-second window.
Recovery:
- window.online event → immediate probe to HEAD /api/health/live
- Periodic probe with exponential backoff: 30s → 60s → 120s → 300s
HALF_OPEN: The probe succeeded but recovery is only confirmed by the first successful real application request.
recordFetchFailure() only for:
- Real network errors (timeout, no route to host)
- Codes 502, 503, 504 (server unreachable)
Do not record:
- 400, 404 (application error, server is reachable)
- 500 (server error, not a network outage)
Semaphore for audio downloads
Limits the number of simultaneous network requests to MinIO to 3.
// src/lib/offline.ts
const MAX_CONCURRENT = 3;
let _active = 0;
const _queue: Array<() => void> = [];
// The first 3 requests go through directly
// The rest wait in a FIFO queue
function _acquire(): Promise<void> {
if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
return new Promise(resolve => _queue.push(() => { _active++; resolve(); }));
}
function _release(): void {
_active--;
_queue.shift()?.(); // Gives the slot to the next in line
}
Specific offline behaviors
What works offline after download?
| Feature | Offline ✅ |
|---|---|
| Navigation between pages | ✅ (app shell + RSC in cache) |
| Reading WORD cards | ✅ (IndexedDB + audio in cache) |
| Reading PHRASE cards | ✅ (IndexedDB + audio positions 1 and 4) |
| MCQ Revision | ✅ (IndexedDB) |
| Statistics recording | ✅ (statistics-queue IndexedDB, sync on reconnection) |
| Audio upload (speaker) | ❌ (requires network) |
| Contact form | ❌ (SMTP requires network) |
Offline navigation: fallback order
- Exact page in cache (
cache.match(request, { ignoreVary: true })) - App shell
"/"— the Next.js client-side router handles display - Static offline page embedded in the SW
localStorage and offline flags
// Downloaded languages (array of UUIDs)
localStorage.getItem("downloaded-languages")
// Example: '["uuid-pul", "uuid-wol"]'
// Last used language
localStorage.getItem("lastLanguageCode")
// Example: '"pul"'
Critical timing: downloaded-languages is updated LAST, after page pre-caching is confirmed via MessageChannel. If the app is closed during download, the language is not marked as available offline.
Removing offline content
// src/lib/offline-download.ts — removeLanguageContent()
// Deletion order (important for consistency):
// 1. Delete audio from the audio cache
// 2. Delete items and cards from IndexedDB
// 3. Delete the language from IndexedDB
// 4. Remove the UUID from localStorage (last)
// Error strategy:
// If a step fails, subsequent steps can still correctly assess the state
Resolved issues (version history)
| Version | Problem | Solution |
|---|---|---|
| v1 | cache.addAll() atomic → a secondary 404 aborted everything |
Promise.allSettled() per resource |
| — | Vary: Accept-Encoding CDN → audio false negatives | Vary stripping + ignoreVary: true |
| v9 | RSC Vary: Next-Router-State-Tree → blank page |
Vary stripping + ignoreSearch: true |
| v10 | Missing /_next/static/*.js chunks → "failed to load chunk" |
CACHE_PAGES extracts and pre-caches chunks via regex |
| v11 | Race condition: client was waiting for a "done" that never came | MessageChannel with heartbeat after each page |
| — | isLanguageDownloaded() = true before pre-caching completed |
localStorage marking LAST |
| — | Audio counted as OK but absent from cache | Post-download verification + re-download of orphans |
| — | Redundant SW controller between check and postMessage() |
Silent try-catch on postMessage() |
Next.js configuration
// next.config.ts
const nextConfig: NextConfig = {
reactStrictMode: true,
output: 'standalone', // Required for Docker
images: {
remotePatterns: [
{ protocol: 'http', hostname: 'minio', port: '9000' }, // Local dev
{ protocol: 'https', hostname: 'racines-s3.id2real.net' }, // Prod MinIO
],
},
};
PWA in production: NEXT_PUBLIC_PWA_ENABLED=true must be set. Without it, the SW is not registered and offline features are not available.
Diagnostics
Check the audio cache
// In the browser console:
const cache = await caches.open('racines-audio-cache-v3');
const keys = await cache.keys();
console.log(`${keys.length} audio files in cache`);
Check IndexedDB
// Open DevTools → Application → IndexedDB → racines-db
// Check stores: languages, cards, items, statistics-queue
Force SW update
# In dev: modify sw.js (browser detects byte-by-byte)
# In production: each CI/CD deployment updates sw.js automatically
# The SW shows the "New version available" banner → click "Reload"
Common issues
| Symptom | Likely cause | Solution |
|---|---|---|
| "New version" banner doesn't disappear | SW in "waiting" state | Click "Reload" or clear cache |
| Silent audio offline | Incorrect AUDIO_CACHE version | Clear cache and re-download |
| "failed to load chunk" offline | Chunks not pre-cached | Re-download the language (CACHE_PAGES v10+ fixes this) |
| Blank page offline | RSC not in cache | Navigate to the page online first |
| SW inactive on /admin | Intentional behavior | SW is disabled on /admin and /speaker |
| Download stuck at 87% | MessageChannel race condition | 30s (inactivity) or 120s (absolute) timeout — continues automatically |