PouchDB und Offline-First
Wer offline Schreibzugriffe erlaubt, wechselt von Request/Response zur Replikation – und der Revisionsbaum von PouchDB und CouchDB macht Konflikte zur Aufgabe des Fachmodells.
Offline gilt als Sonderfall: erst die Anwendung fertig bauen, dann kurz vor dem Release einen Cache davorschalten, fehlgeschlagene Requests abfangen, ein Banner „Du bist offline" anzeigen. Diese Annahme hält genau bis zum ersten Schreibzugriff ohne Netz. Lesen lässt sich zwischenspeichern – Schreiben nicht. Sobald ein Nutzer im Zug einen Datensatz ändern darf, lautet die Frage nicht mehr, wie man Antworten cached, sondern: Wo steht die Wahrheit, solange die Verbindung fehlt? Und was passiert, wenn zwei Geräte in der Zwischenzeit denselben Datensatz geändert haben?
Damit ist Offline-First keine Feature-Checkbox, die man am Ende abhakt. Es ist eine Architekturentscheidung, die das Kommunikationsmodell der Anwendung austauscht: Replikation statt Request/Response. Die Offline-First-Bewegung sagt das seit Jahren – der Begriff stammt aus dem Umfeld von hood.ie und offlinefirst.org, lange bevor Progressive Web Apps das Thema in die Breite getragen haben. Trotzdem wird er im Alltag gern auf „Service Worker plus Cache" verkürzt. Der Service Worker löst aber nur die halbe Aufgabe: Er liefert die Anwendung selbst ohne Netz aus – HTML, JavaScript, CSS. Die Daten sind das eigentliche Problem, und für die braucht es ein anderes Werkzeug.
Replikation statt Request/Response
Im Request/Response-Modell ist der Server die einzige Quelle der Wahrheit. Der Client fragt, der Server antwortet, und ohne Verbindung gibt es schlicht keinen Zustand. Jede Offline-Funktion ist in diesem Modell ein Umweg: eine Queue für ausstehende POSTs, ein Cache für veraltete Antworten, Retry-Logik, lauter Spezialfälle, die quer zur eigentlichen Anwendung liegen.
Im Replikationsmodell dreht sich das um. Die Anwendung liest und schreibt ausschließlich lokal, gegen eine Datenbank im Browser. Synchronisation ist ein eigenständiger Prozess, der im Hintergrund läuft, wenn eine Verbindung da ist – und der pausiert, wenn nicht. Die UI bekommt vom Netz zunächst gar nichts mit.
flowchart LR
subgraph rr["Request/Response"]
UI1["UI"] -->|"HTTP-Request<br/>blockiert ohne Netz"| API["API-Server"]
API --> DB1[("zentrale<br/>Datenbank")]
end
subgraph repl["Replikation"]
UI2["UI"] -->|"lokal lesen<br/>und schreiben"| LOCAL[("PouchDB<br/>IndexedDB")]
LOCAL <-->|"Sync, wenn<br/>Netz da ist"| REMOTE[("CouchDB")]
end
Der Unterschied zeigt sich zuerst in der gefühlten Geschwindigkeit: Lokale Lese- und Schreiboperationen liegen im Millisekundenbereich, unabhängig von Funkloch, Latenz oder Serverlast. Der eigentliche Gewinn und der eigentliche Preis stecken aber woanders. Das Replikationsmodell akzeptiert, dass es zeitweise mehrere gültige Sichten auf dieselben Daten gibt. Konflikte sind darin kein Betriebsunfall, sondern eine erwartbare Folge der Architektur – und genau deshalb gehören sie ins Fachmodell, nicht in einen Fehler-Handler.
PouchDB im Browser, CouchDB am anderen Ende
PouchDB ist eine JavaScript-Datenbank, die im Browser auf IndexedDB aufsetzt und die HTTP-API von CouchDB nachbildet – inklusive des CouchDB-Replikationsprotokolls. Dieses Protokoll ist offen dokumentiert und beschreibt, wie zwei Peers ihre Änderungen austauschen: Changes-Feed lesen, fehlende Revisionen ermitteln, Dokumente übertragen, Checkpoint schreiben. Bricht die Verbindung ab, setzt der nächste Lauf am letzten Checkpoint auf, nicht am Anfang.
Dass sich damit bidirektionale Synchronisation in wenigen Zeilen aufsetzen lässt, wirkt beim ersten Kontakt fast verdächtig:
const localNotes = new PouchDB('notes');
const remoteNotes = new PouchDB('https://couch.example.com/notes');
localNotes.sync(remoteNotes, { live: true, retry: true })
.on('change', info => renderChange(info))
.on('paused', () => setSyncStatus('idle'))
.on('active', () => setSyncStatus('syncing'))
.on('error', err => reportSyncError(err));
sync ist dabei nichts Magisches, sondern zwei einseitige Replikationen, eine in jede Richtung. live hält den Changes-Feed offen, retry überbrückt Verbindungsabbrüche mit Backoff. Die Ereignisse paused und active taugen als ehrliche Statusanzeige: paused heißt „alles übertragen oder gerade kein Netz", active heißt „es fließen Änderungen". Mehr Sync-Code habe ich in kleinen Anwendungen tatsächlich nie gebraucht – der Rest ist Konfliktbehandlung, dazu gleich.
Wichtig für die Einordnung, Stand heute mit PouchDB 7 und CouchDB 2.x: Das Replikationsprotokoll ist kein internes Detail, sondern eine Schnittstelle. Alles, was es spricht, kann teilnehmen – ein CouchDB-Cluster, ein zweiter Browser-Tab, ein Node-Prozess mit express-pouchdb davor. Die Synchronisationslogik wird damit zu Infrastruktur. Was in der Anwendung verbleibt, ist die fachliche Frage, die keine Bibliothek beantworten kann: Was bedeutet es, wenn zwei Stände auseinanderlaufen?
Der Revisionsbaum
Der Kern des Modells ist die Versionierung jedes einzelnen Dokuments. Jede Änderung erzeugt eine neue Revision, und jede Schreiboperation muss die Revision nennen, die sie fortschreiben will. Passt die nicht, lehnt die Datenbank sofort ab:
async function saveNote(note) {
try {
await localNotes.put(note);
} catch (err) {
if (err.status !== 409) throw err;
// optimistic locking: fetch the current revision and retry once
const current = await localNotes.get(note._id);
await localNotes.put({ ...note, _rev: current._rev });
}
}
Dieser 409 ist der unmittelbare Konflikt: zwei Schreiber auf derselben Datenbank, klassisches Optimistic Locking, gut zu behandeln. Interessanter ist der zweite Fall. Zwei Geräte ändern offline dasselbe Dokument, beide Schreiboperationen sind lokal völlig gültig, und erst die Replikation führt die Stände zusammen. Dann entsteht im Dokument ein Revisionsbaum mit zwei Zweigen:
graph TD R1["Rev 1-a4f<br/>gemeinsamer Ausgangsstand"] --> R2A["Rev 2-c91<br/>geändert auf Gerät A"] R1 --> R2B["Rev 2-b07<br/>geändert auf Gerät B"] R2A --> R3["Rev 3-e12<br/>Zusammenführung<br/>durch die Anwendung"] R2B -. "Verlierer-Zweig<br/>bleibt erhalten" .-> R3
CouchDB und PouchDB wählen in dieser Lage deterministisch einen Gewinner: der längere Revisionspfad gewinnt, bei Gleichstand der lexikografisch höhere Revisions-Hash. Deterministisch heißt, jeder Knoten kommt ohne Abstimmung zum selben Ergebnis – ein Cluster braucht dafür keinen Konsens-Algorithmus. Es heißt ausdrücklich nicht: fachlich richtig. Der Verlierer-Zweig wird auch nicht gelöscht, sondern bleibt als _conflicts am Dokument hängen. Die Datenbank hebt beide Stände auf und wartet darauf, dass jemand entscheidet. Dieses „jemand" ist die Anwendung.
Konflikte sind der Normalfall
An dieser Stelle trennt sich die Cache-Denke endgültig vom Replikationsmodell. Wer Offline-Fähigkeit nachrüstet, behandelt Konflikte als Fehler, die möglichst nicht auftreten sollen. Wer repliziert, behandelt sie als gewöhnliches Ereignis mit einer fachlichen Antwort. Die Datenbank kann diese Antwort nicht geben, weil sie die Bedeutung der Daten nicht kennt. Ob bei einer Notiz der längere Text gewinnt, ob sich zwei Einkaufslisten mengenweise vereinigen lassen oder ob ein Mensch entscheiden muss – das steht im Fachmodell, nirgendwo sonst.
Die Auflösung selbst ist mechanisch unspektakulär: Konfliktrevisionen abholen, zusammenführen, Ergebnis schreiben, Verlierer entfernen.
async function resolveConflicts(id) {
const winner = await localNotes.get(id, { conflicts: true });
if (!winner._conflicts) return winner;
const losers = await Promise.all(
winner._conflicts.map(rev => localNotes.get(id, { rev }))
);
const merged = mergeNotes(winner, losers); // domain-specific merge
await localNotes.put(merged);
await Promise.all(
losers.map(loser => localNotes.remove(loser._id, loser._rev))
);
return merged;
}
Entscheidend ist mergeNotes – und dass es diese Funktion überhaupt gibt. Mir ist in Code-Reviews aufgefallen, dass genau sie fast immer fehlt: Sync eingebaut, Demo läuft, und der Konfliktfall wird auf später verschoben, weil er in der Entwicklung schlicht nicht auftritt. Ein einzelner Entwickler mit einem Gerät und stabilem WLAN produziert keine Konflikte. Zwei Außendienstler mit Tablets im Funkloch produzieren sie am ersten Tag. Fehlt dann die Auflösung, gewinnt stillschweigend der deterministische Algorithmus, und die Änderungen des Verlierers sind aus Nutzersicht verschwunden – nicht gelöscht, aber unsichtbar. Das ist die teuerste Sorte Fehler: kein Absturz, kein Log-Eintrag, nur leiser Datenverlust aus Sicht des Menschen vor dem Bildschirm.
Deshalb gehört zur Konfliktstrategie auch eine Sichtbarkeitsstrategie. Eine kleine Anzeige „drei Änderungen warten auf Sync" kostet wenig und verändert das Vertrauen in die Anwendung spürbar. Nutzer verzeihen Wartezeiten, wenn sie sehen, woran sie sind.
Was das mit dem Datenmodell macht
Replikation und Konflikte wirken auf den Dokumentenschnitt zurück. Ein Dokument ist die Einheit, in der repliziert und in der gestritten wird – also lohnt es sich, Dokumente so zu schneiden, dass Konflikte selten und, wenn sie auftreten, auflösbar sind. Ein paar Regeln haben sich bei mir bewährt:
- Ein Dokument pro fachlicher Einheit, die ein Nutzer in einem Zug bearbeitet – kein Sammeldokument für den halben Anwendungszustand.
- Sprechende IDs wie
note:2019-11-12statt reiner Zufalls-UUIDs, wo die Fachlichkeit es hergibt; das macht Direktzugriffe planbar und erspart manchen Index. - Anfügen statt Überschreiben, wo es geht: Wer Ereignisse als neue Dokumente schreibt, statt ein bestehendes zu ändern, bekommt strukturell weniger Konflikte.
- Löschungen als Teil des Modells begreifen: Gelöschte Dokumente bleiben als
_deleted-Revisionen erhalten, damit die Löschung selbst repliziert werden kann.
Für Abfragen jenseits des Schlüsselzugriffs gibt es Mango-Queries über pouchdb-find, inzwischen Teil des Standard-Builds:
await localNotes.createIndex({ index: { fields: ['updatedAt'] } });
const recent = await localNotes.find({
selector: { updatedAt: { $gte: '2019-11-01' } },
sort: [{ updatedAt: 'desc' }],
limit: 20
});
Dazu kommt eine Browser-Realität, die man kennen sollte: IndexedDB-Speicher ist nicht garantiert. Der Browser darf unter Druck aufräumen, und Speicherkontingente unterscheiden sich je nach Plattform deutlich. Über navigator.storage.persist() lässt sich beständiger Speicher anfragen, aber verlassen würde ich mich darauf nicht – die Kopie auf dem Server bleibt die Lebensversicherung, die lokale Datenbank ist der Arbeitsstand.
Zwei eigene Erfahrungen
2016 habe ich mit graphql-pouch ausprobiert, wie weit sich die Idee treiben lässt: eine GraphQL-Laufzeitumgebung direkt auf PouchDB, Schemas in GraphQL-Shorthand-Notation, als eigenständiger Dienst mit CouchDB-Synchronisation und Master/Master-Replikation. Der Prototyp hat mir zwei Dinge gezeigt. Erstens, wie viel Backend das Replikationsprotokoll ersetzt: Sync-Endpunkte, Retry-Logik, Delta-Berechnung – alles Code, den ich nie geschrieben habe, weil das Protokoll ihn mitbringt. Zweitens, dass einen die Konfliktfrage auch serverseitig einholt. Sobald zwei Instanzen gegeneinander replizieren, trägt der „Server" dieselben Revisionsbäume wie der Browser. Die Architekturentscheidung verschwindet also nicht, wenn man sie auf die Serverseite schiebt; sie wechselt nur den Ort.
Nebenbei pflege ich seit einigen Jahren die Liste awesome-pouchdb, in der ich Werkzeuge, Plugins und Artikel rund um PouchDB sammle. Der Blick auf das Ökosystem ist selbst ein Datenpunkt: Auffällig viele Einträge drehen sich um genau die Stellen, an denen das Modell drückt – pouchdb-find für Abfragen, express-pouchdb für eigene Sync-Endpunkte, pouchdb-authentication für die Anmeldung gegen CouchDB. Wo eine Community viele Werkzeuge baut, liegt selten Zufall vor, sondern ein wiederkehrendes Problem.
Wo ich es nicht einsetzen würde
So überzeugend das Modell für Notizen, Formulare und Außendienst-Szenarien ist – es hat klare Grenzen, und die sollte man vor der Entscheidung kennen, nicht danach:
- Feingranulare Zugriffsrechte: CouchDB autorisiert pro Datenbank, nicht pro Dokument. Das übliche Gegenmittel ist eine Datenbank pro Nutzer, was Betrieb und serverseitige Auswertung merklich verkompliziert.
- Große, zentral auszuwertende Datenmengen: Was der Browser hält, muss dorthin repliziert werden. Für Dashboards über Millionen Zeilen ist der Ansatz das falsche Werkzeug.
- Streng konsistente Abläufe: Kontostände, Lagerbestände, Buchungen mit harten Invarianten brauchen zentrale Koordination. Eventual Consistency lässt sich nicht wegkonfigurieren.
- Daten, die das Endgerät nicht verlassen dürfen es aber täten – oder umgekehrt gar nicht erst darauf liegen dürfen. Replikation verteilt Kopien, das ist ihr Zweck.
Gefilterte Replikation kann die Datenmenge pro Client eingrenzen, hat aber ihren eigenen Preis: Der Filter läuft über den kompletten Changes-Feed, und nachträgliche Filteränderungen sind mühsam. Wer nur einen Teilbestand synchronisieren will, sollte das früh im Dokumentenschnitt berücksichtigen, etwa über getrennte Datenbanken je Mandant oder Projekt.
Unterm Strich
Offline-First entscheidet man am Anfang eines Projekts, nicht am Ende – weil Dokumentenschnitt, Konfliktverhalten und Berechtigungsmodell daran hängen. PouchDB und CouchDB nehmen einem die Mechanik vollständig ab: Replikationsprotokoll, Revisionsbäume, Checkpoints, Wiederaufnahme nach Abbruch. Was sie einem nicht abnehmen, ist die fachliche Antwort auf die eine Frage, an der alles hängt: Was soll passieren, wenn zwei Geräte dasselbe geändert haben? Wer darauf eine Antwort hat, bekommt mit erstaunlich wenig Code eine Anwendung, die im Funkloch genauso funktioniert wie im Büro. Wer keine hat, bekommt sie trotzdem – nur entscheidet dann ein Hash-Vergleich über die Daten seiner Nutzer.
Kommentare