🌐 Detecting your location…

So erstellen Sie eine Offline-First-PWA im Jahr 2026: Service Workers und IndexedDB Guide

⏱️7 min read  ·  1,522 words

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.

Die Architektur

  1. Servicemitarbeiter fängt Netzwerkanfragen ab und stellt sie aus dem Cache bereit
  2. Cache-Speicher enthält die Anwendungs-Shell – HTML, CSS, JavaScript, Schriftarten
  3. IndexedDB enthält strukturierte Daten – zeichnet die Lese- und Schreibvorgänge des Benutzers auf
  4. 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.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work — which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা