In der Theorie sollte eine stabile Internetverbindung selbstverständlich sein. In der Praxis, etwa bei Außendienstmitarbeiter:innen oder in Lagerhallen mit schlechtem Empfang, sieht die Realität oft anders aus. Daher war bei meinem aktuellen Projekt klar: Die App muss auch offline funktionieren. Daten müssen lokal gespeichert, bearbeitet und später synchronisiert werden können.
In diesem Beitrag zeige ich, wie ich mittels IndexedDB eine Local-First-Architektur für meine Nuxt-App umgesetzt habe – inklusive Upload-Queue, Offline-Sync und PWA-Support.
Das Problem: Netzwerk ist nicht garantiert
Nutzer:innen sollen Datensätze anlegen, bearbeiten und Dateien hochladen können – auch ohne Internetverbindung. Sobald die Verbindung wieder steht, müssen alle Änderungen automatisch synchronisiert werden.
Die Herausforderungen:
- Lokale Datenhaltung – Alle relevanten Daten müssen offline verfügbar sein
- Upload-Queue – Formulardaten und Dateien müssen zwischengespeichert und später hochgeladen werden
- Abhängigkeiten – Manche Datensätze müssen erst angelegt werden, bevor zugehörige Dateien hochgeladen werden können
- Sync-Logik – Server-Daten müssen inkrementell abgeglichen werden
Die Lösung: IndexedDB + Upload-Queue + PWA
1. IndexedDB als zentrale Datenschicht
Alle relevanten Daten werden in IndexedDB gespeichert. Die Struktur ist simpel: Ein Store pro Datentyp. Ich habe Stores für verschiedene Entitäten angelegt – von Stammdaten über Dateien bis hin zur Upload-Queue selbst.
Die Datenbankverbindung wird als Singleton gecacht, sodass nicht bei jedem Zugriff eine neue Verbindung geöffnet wird. Das spart Ressourcen und beschleunigt den Zugriff erheblich.
Für die Arbeit mit IndexedDB habe ich Helper-Funktionen geschrieben: getItem(), setItem(), getAllItems(), removeItem() und clearStore(). Sie kapseln die etwas umständliche IndexedDB-API und machen den Code deutlich lesbarer.
2. Upload-Queue für Offline-Operationen
Das Herzstück der Offline-Funktionalität ist die Upload-Queue. Sie speichert alle Aktionen, die offline durchgeführt werden, und arbeitet sie ab, sobald die Verbindung wieder steht.
Wie die Queue funktioniert
Jedes Queue-Item hat einen Typ, einen Payload, einen Status, eine Priorität und optional eine Abhängigkeit zu einem anderen Item. Der Status kann pending, uploading, done oder error sein.
Für jeden Upload-Typ wird ein Handler registriert, der die eigentliche API-Kommunikation übernimmt. Ich habe Handler für verschiedene Entitäten: Systeme, Kunden, Standorte und Dateien.
Handler für Dateien sind etwas komplexer, da Blobs aus IndexedDB geladen und in Base64 konvertiert werden müssen, bevor sie an die Catalyst-Function übergeben werden.
Abhängigkeiten auflösen
Ein Datensatz muss erst angelegt werden, bevor zugehörige Bilder hochgeladen werden können. Die Queue löst das über Platzhalter: Wenn ein Bild hochgeladen werden soll, kann im Payload auf die ID des übergeordneten Datensatzes verwiesen werden – auch wenn diese noch gar nicht existiert.
Die resolvePlaceholders()-Funktion ersetzt diese Platzhalter durch die tatsächlichen Werte aus den Ergebnissen vorheriger Queue-Items. So können komplexe Abhängigkeitsketten abgebildet werden.
Automatische Verarbeitung
Die Queue wird automatisch verarbeitet, sobald:
- Die App gestartet wird (falls noch unverarbeitete Items vorhanden sind)
- Ein neues Item hinzugefügt wird
- Die Internetverbindung wiederhergestellt wird (über das
online-Event)
Das bedeutet: Nutzer:innen müssen sich um nichts kümmern. Sie arbeiten einfach weiter, und sobald die Verbindung steht, werden alle Änderungen automatisch synchronisiert.
3. Inkrementeller Sync für Server-Daten
Neben dem Upload von lokalen Änderungen müssen auch Server-Daten regelmäßig abgeglichen werden. Dafür habe ich einen generischen Sync-Mechanismus gebaut.
Jede Tabelle hat eine Sync-Konfiguration mit Store-Name, Function-Name und Response-Key. Der Sync-Prozess läuft so ab:
- Letzter Sync-Zeitstempel aus IndexedDB laden
- Server-Function mit diesem Zeitstempel aufrufen
- Nur geänderte Datensätze werden zurückgegeben
- Lokale Daten aktualisieren oder löschen (bei
is_active = false) - Neuen Sync-Zeitstempel speichern
Das spart Bandbreite und beschleunigt den Sync erheblich. Statt jedes Mal alle Daten zu laden, werden nur die Änderungen seit dem letzten Sync übertragen.
4. PWA-Support mit Vite PWA
Die App ist als Progressive Web App (PWA) konfiguriert. Das bedeutet:
- Sie kann auf dem Homescreen installiert werden
- Ein Service Worker cached statische Assets
- Die App funktioniert auch komplett offline
Ich nutze das @vite-pwa/nuxt-Modul, das die gesamte PWA-Konfiguration übernimmt. Ein kleines Plugin sorgt dafür, dass die Seite automatisch neu geladen wird, wenn eine neue Version verfügbar ist.
5. Konfliktauflösung bei gleichzeitigen Änderungen
Bei Offline-Anwendungen ist es unvermeidbar, dass mehrere Nutzer:innen denselben Datensatz offline bearbeiten. Wenn beide später synchronisieren, entsteht ein Konflikt.
Aktuell nutzt die App Last-Write-Wins: Die zuletzt synchronisierte Änderung überschreibt vorherige Versionen. Das ist simpel, aber nicht ideal – Daten können verloren gehen.
Eine robustere Konfliktauflösung mit Versionierung und manueller Konfliktlösung steht noch auf der TODO-Liste. Für den aktuellen Anwendungsfall (einzelne Nutzer:innen pro Datensatz) ist Last-Write-Wins ausreichend.
6. Robuste Fehlerbehandlung mit Retry-Strategie
Nicht jeder Fehler bedeutet, dass die Verbindung weg ist. Die Queue unterscheidet zwischen temporären und permanenten Fehlern:
- Temporäre Fehler (5xx, Timeout, Network Error): Exponential Backoff mit maximal 5 Versuchen
- Permanente Fehler (4xx außer 408): Item wird als `failed` markiert, User-Benachrichtigung
- Auth-Fehler (401, 403): Token-Refresh wird ausgelöst, danach erneuter Versuch
Die Retry-Delays folgen dem Pattern: 1s, 2s, 4s, 8s, 16s. Das verhindert, dass der Server bei Problemen überlastet wird.
7. Speicher-Management und Quota-Überwachung
IndexedDB hat Browser-abhängige Limits (typisch 50MB bis 2GB). Um Quota-Probleme zu vermeiden:
- Die App prüft regelmäßig die verfügbare Quota mit der Storage API
- Bei 80% Auslastung wird eine Warnung angezeigt
- Bilder werden vor dem Speichern komprimiert (max. 1920px Breite, 85% JPEG-Qualität)
Ein LRU-Cache sorgt dafür, dass selten genutzte Datensätze bei Bedarf aus IndexedDB entfernt werden können.
Wie es in der Praxis aussieht
Szenario 1: Offline arbeiten
Ein:e Nutzer:in ist in einem Gebäude ohne Empfang unterwegs. Sie legt einen neuen Datensatz an, lädt drei Bilder hoch und bearbeitet einen bestehenden Eintrag.
Alle Aktionen werden sofort in der Upload-Queue gespeichert. Die UI zeigt an, dass die Daten lokal gespeichert sind. Sobald die Person wieder Empfang hat, werden alle Änderungen automatisch hochgeladen – ohne dass sie etwas tun muss.
Szenario 2: Schlechte Verbindung
Die Verbindung bricht während eines Uploads ab. Die Queue markiert das Item als error und versucht es beim nächsten processQueue()-Aufruf erneut. Nutzer:innen können in der Zwischenzeit weiterarbeiten.
Szenario 3: Komplexe Abhängigkeiten
Ein System wird angelegt, drei Bilder sollen hochgeladen werden. Das System-Item bekommt Priorität 1, die Bild-Items Priorität 2 und eine Abhängigkeit zum System-Item.
Die Queue verarbeitet erst das System, speichert die zurückgegebene ID und ersetzt dann in den Bild-Items den Platzhalter durch diese ID. Alle drei Bilder werden korrekt dem neuen System zugeordnet.
Fazit
Die Kombination aus IndexedDB, Upload-Queue, PWA und durchdachter Konfliktauflösung macht aus einer normalen Web-App eine vollwertige Offline-Anwendung. Nutzer:innen können unterbrechungsfrei arbeiten, egal ob mit oder ohne Internetverbindung.
Das Setup erfordert etwas initiale Arbeit, zahlt sich aber schnell aus. Die Architektur ist erweiterbar: Neue Entitäten können einfach durch Hinzufügen eines Handlers und einer Sync-Konfiguration integriert werden.
Wichtig ist, von Anfang an in Local-First zu denken. Nicht “Was passiert, wenn die Verbindung weg ist?”, sondern “Wie funktioniert die App offline, und wie synchronisiere ich später?”. Dieser Mindset-Shift macht den Unterschied zwischen einer Web-App mit Offline-Modus und einer echten Local-First-Anwendung.
Die größten Herausforderungen liegen nicht in der technischen Umsetzung, sondern in den Edge Cases: Konfliktauflösung, Fehlerbehandlung, Speicher-Management und Security. Wer diese Aspekte von Anfang an mitdenkt, baut eine robuste Lösung, die auch unter schwierigen Bedingungen zuverlässig funktioniert.