Offline-first bedeutet, dass die Anwendung standardmäßig mit lokalen Daten arbeitet und das Netzwerk als Erweiterung behandelt. Das ist ein anderes Design als „Funktioniert online, wird offline schlechter“ und es entsteht eine App, die sich auch bei einer guten Verbindung sofort anfühlt. Dieser Leitfaden baut einen richtig auf: Service-Worker-Caching, IndexedDB für strukturierte Daten und Hintergrundsynchronisierung für Offline-Schreibvorgänge.
📋 Table of Contents
- Die Architektur
- Schritt 1: Das Manifest
- Schritt 2: Registrieren des Servicemitarbeiters
- Schritt 3: Der Service Worker und seine Caching-Strategien
- Schritt 4: IndexedDB für strukturierte Daten
- Schritt 5: Hintergrundsynchronisierung
- Umgang mit Konflikten
- Speicherbeschränkungen
- Offline richtig testen
- Häufige Fehler
- Fazit
Die Architektur
- Servicemitarbeiter fängt Netzwerkanfragen ab und stellt sie aus dem Cache bereit
- Cache-Speicher enthält die Anwendungs-Shell – HTML, CSS, JavaScript, Schriftarten
- IndexedDB enthält strukturierte Daten – zeichnet die Lese- und Schreibvorgänge des Benutzers auf
- Hintergrundsynchronisierung Wiederholt Offline-Schreibvorgänge, sobald die Verbindung wiederhergestellt ist
Der entscheidende mentale Wandel: Die Benutzeroberfläche liest aus IndexedDB, niemals direkt aus dem Netzwerk. Ein separater Prozess hält IndexedDB aktuell. Diese einzige Entscheidung sorgt dafür, dass die App online und offline identisch funktioniert.
Schritt 1: Das Manifest
{
"name": "Field Notes",
"short_name": "Notes",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">
Das maskierbare Symbol ist auf Android wichtig, wo ein nicht maskierbares Symbol in eine Form zugeschnitten wird, die oft einen Teil Ihres Logos abschneidet.
Schritt 2: Registrieren des Servicemitarbeiters
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
// Notify the user when a new version is waiting.
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner(() => {
newWorker.postMessage({ type: 'SKIP_WAITING' });
});
}
});
});
} catch (err) {
console.error('Service worker registration failed:', err);
}
});
}
// Reload once the new worker takes control.
let refreshing = false;
navigator.serviceWorker?.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
Erzwingen Sie niemals die Aktivierung eines neuen Workers, ohne den Benutzer darüber zu informieren. Das Austauschen des Codes unter einer laufenden Anwendung während der Sitzung führt zu verwirrenden Fehlern – bieten Sie stattdessen ein Neuladen an.
Schritt 3: Der Service Worker und seine Caching-Strategien
Unterschiedliche Ressourcen erfordern unterschiedliche Strategien, und die Verwendung einer Strategie für alles ist der übliche Fehler.
| Strategie | Verwenden Sie für |
|---|---|
| Zuerst zwischenspeichern | Gehaschte statische Assets – sie ändern sich nie |
| Netzwerk zuerst | API-Daten, bei denen es auf Aktualität ankommt |
| Veraltet während der erneuten Validierung | Inhalte, die etwas alt sein können – Avatare, Einträge |
| Nur Netzwerk | Alles mit Nebenwirkungen |
// sw.js
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;
const SHELL_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/styles.css',
'/app.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then(c => c.addAll(SHELL_ASSETS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k))
);
await self.clients.claim();
})());
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Never cache anything that changes server state.
if (request.method !== 'GET') return;
const url = new URL(request.url);
// Navigations: network first, fall back to the offline page.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
return;
}
// API: network first, fall back to the cached copy.
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache first.
event.respondWith(cacheFirst(request));
});
async function networkFirst(request) {
const cache = await caches.open(DATA_CACHE);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return new Response(
JSON.stringify({ error: 'offline' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(SHELL_CACHE);
cache.put(request, response.clone());
}
return response;
}
response.clone() ist erforderlich, da ein Antworttext nur einmal gelesen werden kann. Durch das Zwischenspeichern und Zurückgeben des Originals an die Seite wird es zweimal verbraucht und die Seite erhält einen leeren Textkörper.
Schritt 4: IndexedDB für strukturierte Daten
Der Cache-Speicher speichert ganze HTTP-Antworten. Für Datensätze, die Sie einzeln abfragen, sortieren und aktualisieren müssen, verwenden Sie IndexedDB. Die Roh-API ist ausführlich, daher lohnt sich ein dünner Wrapper.
import { openDB } from 'idb';
const db = await openDB('field-notes', 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
const notes = db.createObjectStore('notes', { keyPath: 'id' });
notes.createIndex('by-updated', 'updatedAt');
notes.createIndex('by-synced', 'synced');
}
if (oldVersion < 2) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
}
},
});
export async function saveNote(note) {
const record = { ...note, updatedAt: Date.now(), synced: false };
await db.put('notes', record);
await db.add('outbox', { type: 'saveNote', payload: record });
await requestSync();
return record;
}
export async function listNotes() {
return db.getAllFromIndex('notes', 'by-updated');
}
Beachten Sie die Reihenfolge: Zuerst in den lokalen Speicher schreiben und dann die Synchronisierung in die Warteschlange stellen. Die Benutzeroberfläche wird sofort aktualisiert und kümmert sich nicht darum, ob das Netzwerk verfügbar ist. Das ist der Sinn von Offline-First.
Schritt 5: Hintergrundsynchronisierung
Mit der Hintergrundsynchronisierung kann der Browser Schreibvorgänge in der Warteschlange wiedergeben, sobald die Verbindung wiederhergestellt ist, selbst wenn die Seite geschlossen wurde.
// In the page
async function requestSync() {
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) {
await reg.sync.register('sync-outbox');
} else {
await flushOutbox(); // fall back to an immediate attempt
}
}
// In sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-outbox') {
event.waitUntil(flushOutbox());
}
});
async function flushOutbox() {
const db = await openDB('field-notes', 2);
const items = await db.getAll('outbox');
for (const item of items) {
try {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await db.delete('outbox', item.id);
await db.put('notes', { ...item.payload, synced: true });
} catch {
// Leave it queued; the sync event fires again later.
return;
}
}
}
Die Hintergrundsynchronisierung ist nicht in jedem Browser verfügbar. Behalten Sie immer einen Fallback bei, der den Postausgang leert, wenn die Seite geladen wird und wenn einonline Ereignisbrände.
Umgang mit Konflikten
Offline-Schreibvorgänge führen zu Konflikten, und das Vortäuschen von etwas anderem führt zu stillem Datenverlust. Entscheiden Sie sich explizit für eine Richtlinie.
Letzter Schreibvorgang gewinnt Am einfachsten ist es, einen Zeitstempel zu senden und den Server den neueren übernehmen zu lassen. Ausreichend für persönliche Notizen, falsch für alles, was kollaborativ ist.
Server gewinnt bei Konflikt ist sicher und erfordert, dass der Benutzer darüber informiert wird, dass seine Offline-Änderung abgelehnt wurde. Werfen Sie es niemals stillschweigend weg.
Zusammenführen ist dort am besten, wo die Datenstruktur dies unterstützt, was normalerweise ein CRDT für wirklich kollaborative Dokumente bedeutet.
const res = await fetch('/api/notes/' + note.id, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Unmodified-Since': new Date(note.serverUpdatedAt).toUTCString(),
},
body: JSON.stringify(note),
});
if (res.status === 412) {
await queueConflictForReview(note); // surface it, never drop it
}
Speicherbeschränkungen
Unter Druck räumen Browser Speicherplatz aus. Für eine App, deren lokale Daten bis zur Synchronisierung die Quelle der Wahrheit sind, fordern Sie Persistenz an.
if (navigator.storage?.persist) {
const persisted = await navigator.storage.persist();
console.log('Persistent storage:', persisted);
}
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1024 / 1024).toFixed(1)}MB of ${(quota / 1024 / 1024).toFixed(0)}MB`);
Offline richtig testen
Das Offline-Kontrollkästchen von DevTools ist ein Ausgangspunkt, kein Test. Überprüfen Sie außerdem: Neuladen im Offline-Modus, eine langsame und instabile Verbindung statt einer völlig fehlenden, was passiert, wenn der Servicemitarbeiter mitten in der Sitzung aktualisiert, und ob ein Schreibvorgang in der Warteschlange einen vollständigen Neustart des Browsers übersteht.
# Verify the manifest, service worker, and installability
npx lighthouse https://your-app.com --only-categories=pwa --view
Häufige Fehler
POST-Anfragen zwischenspeichern. Cache Storage verarbeitet nur GET, und das Zwischenspeichern von Anfragen mit Nebeneffekten wäre ohnehin falsch.
Vergessenresponse.clone(). Die Seite erhält einen leeren Body.
Alte Caches niemals ungültig machen. Benutzer erhalten auf unbestimmte Zeit veraltete Assets. Versions-Cache-Namen und alte beim Aktivieren löschen.
AufrufskipWaiting() bedingungslos. Codeänderungen während einer laufenden Sitzung.
Lesen aus dem Netzwerk in der Benutzeroberfläche. Aus IndexedDB lesen und separat synchronisieren, oder die App ist nicht zuerst offline.
Fehlgeschlagene Offline-Schreibvorgänge werden gelöscht. Stiller Datenverlust ist die schlimmste mögliche Folge – immer treten Konflikte an der Oberfläche auf.
Fazit
Offline-First basiert auf einer architektonischen Entscheidung:the UI reads and writes local storage, and synchronisation happens separately in the background. Cachen Sie die Anwendungs-Shell mit einem versionierten Service-Worker, behalten Sie strukturierte Daten in IndexedDB, stellen Sie Schreibvorgänge in einen Postausgang und geben Sie sie mit der Hintergrundsynchronisierung wieder, und wählen Sie bewusst eine Konfliktrichtlinie aus, anstatt sie zufällig zu entdecken. Test with a flaky connection rather than a clean offline toggle, because intermittent connectivity is what users actually experience.
🔗 Share this article
✍️ Leave a Comment