React Native Offline-First mit WatermelonDB aufbauen
AI generated
RN
native
React Native · Offline-First · WatermelonDB · Mobile Apps
Offline-First mit WatermelonDB aufbauen
von SQLite-Schema bis zur Sync-Engine

Wer mobile Apps ohne Offline-First-Architektur baut, verliert Nutzer an jedem schwachen Funkloch. WatermelonDB kombiniert einen SQLite-Unterbau, Lazy Loading und reaktive Observables mit einer eingebauten Sync-Engine, sodass React-Native-Apps lokal sofort reagieren und im Hintergrund zuverlässig mit dem Server synchronisieren.

18 Min. Lesezeit appSchema · Decorators · synchronize() · Observables React Native · WatermelonDB 0.27 · SQLite · JSI

1. Was Offline-First bedeutet und warum mobile Apps es brauchen

Offline-First bedeutet, dass eine App ein lokales, vollständig funktionsfähiges Speichersystem als primäre Datenquelle behandelt und das Backend nur als Synchronisationsziel im Hintergrund betrachtet. Für React-Native-Anwendungen ist dieser Ansatz kein Komfortfeature, sondern eine Grundvoraussetzung, sobald Nutzer in der U-Bahn, im Ausland ohne Datenroaming oder in Gebäuden mit schwachem Empfang mit der App arbeiten. WatermelonDB wurde genau für dieses Szenario entwickelt: Jede Lese- und Schreiboperation läuft gegen die lokale SQLite-Datenbank, niemals direkt gegen einen Server, und die Synchronisation läuft als separater, asynchroner Prozess im Hintergrund.

Der Unterschied zu einer klassischen Client-Server-App zeigt sich sofort in der wahrgenommenen Geschwindigkeit. Während eine netzwerkgetriebene App bei jedem Tippen auf einen Button auf eine Serverantwort wartet, reagiert eine mit WatermelonDB gebaute Offline-First-App innerhalb weniger Millisekunden, weil die Anfrage das Gerät nie verlässt. Genau diese Reaktionsgeschwindigkeit erwarten Nutzer heute von jeder mobilen App, unabhängig von der tatsächlichen Netzqualität am jeweiligen Standort.

Ein dritter Aspekt betrifft Datenverlust und Konsistenz. Ohne Offline-First-Architektur führt ein Verbindungsabbruch während des Speicherns häufig zu inkonsistenten Zuständen oder verlorenen Eingaben. WatermelonDB begegnet diesem Problem, indem jede Änderung sofort und dauerhaft in SQLite geschrieben wird, während ein internes Change-Log im Hintergrund vermerkt, welche Datensätze noch zum Server synchronisiert werden müssen.

2. WatermelonDB Architektur im Überblick

Unter der Haube setzt WatermelonDB auf eine native SQLite-Datenbank pro Plattform, die über eine JSI-Bridge (JavaScript Interface) direkt aus dem JavaScript-Thread angesprochen wird, ohne den klassischen asynchronen Bridge-Umweg über JSON-Serialisierung. Diese JSI-Anbindung ist der entscheidende Performance-Unterschied zu älteren Lösungen: Abfragen werden auf nativem Niveau ausgeführt, während die eigentliche Datenverarbeitung weiterhin asynchron im JavaScript-Layer erfolgt.

Das zweite zentrale Architekturprinzip ist Lazy Loading. Eine WatermelonDB-Query gibt nicht sofort ein Array von Objekten zurück, sondern ein Query-Objekt, das erst bei tatsächlichem Zugriff, etwa über fetch() oder als Observable, die Daten lädt. Das erlaubt es, in einer Liste mit zehntausend Datensätzen nur die tatsächlich sichtbaren Zeilen zu materialisieren, statt beim Start der App die komplette Tabelle in den Speicher zu laden.

Das dritte Prinzip sind Observables auf Basis von RxJS. Jede Query kann als Observable Stream abonniert werden, der bei jeder relevanten Datenänderung automatisch einen neuen Wert emittiert. In Kombination mit withObservables in React-Komponenten entsteht daraus ein reaktives System, in dem sich die Oberfläche ohne manuelles Neuladen oder Redux-ähnliches State-Management aktualisiert, sobald sich die zugrunde liegenden WatermelonDB-Datensätze ändern.

3. Setup und Schema-Definition

Die Installation von WatermelonDB erfolgt über npm, gefolgt von der Einrichtung des Babel-Plugins für Decorators, das für die Model-Definitionen im nächsten Abschnitt benötigt wird. Unter iOS ist zusätzlich ein pod install nötig, da WatermelonDB einen nativen SQLite-Adapter mitbringt, der als CocoaPod eingebunden wird. Auf Android greift der native Adapter direkt über JSI, ohne zusätzliche Gradle-Konfiguration in den meisten Standard-Setups.


# Install WatermelonDB and its React binding
npm install @nozbe/watermelondb @nozbe/with-observables

# Decorators (used for @field, @relation, @children) need this Babel plugin
npm install --save-dev @babel/plugin-proposal-decorators

# iOS: link the native SQLite adapter via CocoaPods
cd ios && pod install && cd ..

# Android/iOS: rebuild the native app after install
npx react-native run-android
npx react-native run-ios

{
  "dependencies": {
    "@nozbe/watermelondb": "^0.27.1",
    "@nozbe/with-observables": "^1.6.0",
    "react-native": "0.74.0"
  },
  "devDependencies": {
    "@babel/plugin-proposal-decorators": "^7.24.0"
  },
  "scripts": {
    "postinstall": "pod-install"
  }
}

Das Schema wird zentral über appSchema() definiert und beschreibt jede Tabelle mit tableSchema(), inklusive aller Spalten und deren Typen. WatermelonDB unterstützt die Typen string, number und boolean für Spalten. Komplexere Strukturen wie verschachtelte Objekte werden entweder in eigene Tabellen mit Relationen ausgelagert oder als JSON-String in einer string-Spalte gespeichert. Wichtig ist isIndexed bei Fremdschlüssel-Spalten wie project_id, damit Relations-Queries performant bleiben, auch wenn eine Tabelle mehrere hunderttausend Zeilen enthält.


import { appSchema, tableSchema } from '@nozbe/watermelondb'

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'tasks',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'is_completed', type: 'boolean' },
        { name: 'project_id', type: 'string', isIndexed: true },
        { name: 'created_at', type: 'number' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
    tableSchema({
      name: 'projects',
      columns: [
        { name: 'name', type: 'string' },
        { name: 'created_at', type: 'number' },
      ],
    }),
  ],
})

Jede Schema-Änderung erhöht die version-Nummer und erfordert eine Migration, die WatermelonDB über schemaMigrations() bereitstellt. Ohne registrierte Migration bricht die App beim nächsten Start ab, sobald ein Nutzer mit altem Schema die neue Version installiert. Dieser Migrationszwang wirkt zunächst unbequem, verhindert aber genau die Art von stillen Schema-Inkonsistenzen, die bei ungeplanten SQLite-Änderungen sonst zu Crashes im Feld führen.

4. Modelle und Decorators

Jede Tabelle bekommt eine korrespondierende Model-Klasse, die von Model aus @nozbe/watermelondb erbt. Die statische Eigenschaft table verweist auf den Tabellennamen aus dem Schema, associations beschreibt die Beziehungstypen belongs_to und has_many zu anderen Modellen. Innerhalb der Klasse machen Decorators wie @field, @relation, @children und @date die zugrunde liegenden SQLite-Spalten als normale JavaScript-Properties zugänglich, inklusive automatischer Typkonvertierung zwischen SQLite-Rohwerten und JavaScript-Typen.

Der @field-Decorator bindet eine einfache Spalte direkt an eine Property. Lesezugriffe liefern den aktuellen Wert, Schreibzugriffe müssen innerhalb eines database.write()-Blocks erfolgen, da WatermelonDB alle Mutationen in Transaktionen kapselt. Der @relation-Decorator liefert bei Zugriff ein einzelnes verknüpftes Model-Objekt zurück, etwa das Project zu einem Task über project_id, während @children eine Query zurückgibt, die alle verknüpften Kind-Datensätze repräsentiert, etwa alle Tasks eines Projekts.


import { Model } from '@nozbe/watermelondb'
import { field, date, relation, children, readonly } from '@nozbe/watermelondb/decorators'

export class Task extends Model {
  static table = 'tasks'
  static associations = {
    projects: { type: 'belongs_to', key: 'project_id' },
  }

  @field('title') title
  @field('is_completed') isCompleted
  @relation('projects', 'project_id') project
  @readonly @date('created_at') createdAt
  @readonly @date('updated_at') updatedAt
}

export class Project extends Model {
  static table = 'projects'
  static associations = {
    tasks: { type: 'has_many', foreignKey: 'project_id' },
  }

  @field('name') name
  @children('tasks') tasks
}

Der @readonly-Decorator in Kombination mit @date markiert Felder wie createdAt und updatedAt als vom Framework verwaltet: WatermelonDB aktualisiert updatedAt automatisch bei jeder Änderung des Datensatzes, ohne dass die Anwendungslogik das Feld selbst pflegen muss. Diese Kombination aus Decorators reduziert Boilerplate erheblich gegenüber einem manuellen SQLite-Wrapper, bei dem jede Spalten-Zugriffslogik von Hand geschrieben werden müsste.

5. Eine Sync-Engine implementieren

Der Kern jeder Offline-First-Architektur mit WatermelonDB ist die synchronize()-Funktion aus @nozbe/watermelondb/sync, die zwei Callbacks erwartet: pullChanges und pushChanges. pullChanges bekommt den Zeitstempel der letzten erfolgreichen Synchronisation (lastPulledAt) übergeben und muss vom Server alle seither geänderten, erstellten und gelöschten Datensätze abrufen, gruppiert nach Tabelle in ein changes-Objekt mit den Schlüsseln created, updated und deleted.

pushChanges erhält im Gegenzug genau die lokalen Änderungen, die seit dem letzten Sync-Zyklus in WatermelonDB angefallen sind, und muss diese an das Backend übertragen. WatermelonDB führt intern ein Change-Log, das jede Mutation seit dem letzten erfolgreichen Push nachverfolgt, sodass pushChanges niemals den kompletten lokalen Datenbestand überträgt, sondern nur das Delta. Das hält Sync-Zyklen auch bei großen Datenmengen schlank und reduziert Backend-Last spürbar gegenüber einem naiven Full-Sync-Ansatz.


import { synchronize } from '@nozbe/watermelondb/sync'
import { database } from './database'

export async function syncWithServer() {
  await synchronize({
    database,
    pullChanges: async ({ lastPulledAt }) => {
      const response = await fetch(
        'https://api.example.com/sync?last_pulled_at=' + (lastPulledAt || 0)
      )
      if (!response.ok) {
        throw new Error('Pull failed with status ' + response.status)
      }
      const body = await response.json()
      return { changes: body.changes, timestamp: body.timestamp }
    },
    pushChanges: async ({ changes, lastPulledAt }) => {
      const response = await fetch('https://api.example.com/sync', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ changes, last_pulled_at: lastPulledAt }),
      })
      if (!response.ok) {
        throw new Error('Push failed with status ' + response.status)
      }
    },
    migrationsEnabledAtVersion: 1,
  })
}

Die synchronize()-Funktion selbst kapselt Transaktionssicherheit: Schlägt pushChanges fehl, etwa durch einen Netzwerkabbruch mitten im Request, bleiben die lokalen Änderungen im Change-Log erhalten und werden beim nächsten Aufruf erneut übertragen. Ein migrationsEnabledAtVersion-Parameter sorgt zusätzlich dafür, dass Schema-Migrationen korrekt in den Sync-Protokoll-Zyklus eingebettet werden, wenn sich das lokale Schema seit dem letzten Sync geändert hat.

6. Reaktive Queries mit Observables in Komponenten

withObservables aus @nozbe/with-observables verbindet eine React-Komponente mit einem oder mehreren WatermelonDB-Observables und sorgt dafür, dass die Komponente automatisch neu rendert, sobald sich die beobachteten Daten ändern, ohne dass Redux, Context oder manuelles Refetching nötig wären. Der typische Aufruf withObservables(['task'], (props) => ({ task: props.task.observe() })) abonniert genau einen Task-Datensatz. Ändert sich sein Titel oder Status irgendwo in der App, aktualisiert sich jede Komponente, die diesen Task beobachtet, praktisch augenblicklich.

Für Listen nutzt man stattdessen tasksCollection.query(Q.where('is_completed', false)).observe(), das ein Array-Observable liefert und bei jedem Insert, Update oder Delete, das die Query-Bedingung betrifft, neu emittiert. Weil WatermelonDB Query-Ergebnisse lazy lädt, bleibt diese reaktive Kopplung auch bei Listen mit tausenden Einträgen performant, da nur die Differenz zwischen altem und neuem Ergebnis tatsächlich neu gerendert wird, ähnlich dem Diffing-Verhalten von React selbst auf Datenebene.

Ein wichtiger Praxishinweis: withObservables sollte möglichst weit unten im Komponentenbaum ansetzen, nahe an der tatsächlichen Anzeige, statt eine ganze Bildschirmkomponente zu umschließen. So löst eine einzelne Änderung an einem Task nicht das Neurendern einer kompletten Liste aus, sondern nur der betroffenen Zeile, was gerade bei langen, scrollbaren Listen den spürbaren Unterschied zwischen einer flüssigen und einer ruckelnden Offline-First-App ausmacht.

7. Konfliktlösung bei der Synchronisation

Sobald mehrere Geräte oder ein Gerät und ein Server denselben Datensatz offline verändern können, entstehen zwangsläufig Konflikte, die eine Sync-Engine auflösen muss. WatermelonDB selbst trifft keine Entscheidung über die Konfliktstrategie, sondern überlässt sie bewusst der Backend-Implementierung von pullChanges und pushChanges. Die gängigste Strategie ist last-write-wins auf Basis von updatedAt-Zeitstempeln: Der Datensatz mit dem jüngeren Zeitstempel gewinnt, der ältere wird verworfen.

Alternativ setzen viele Teams auf Server-Autorität: Der Server entscheidet im Zweifel immer, unabhängig vom Zeitstempel, welche Version als kanonisch gilt, und der Client übernimmt beim nächsten pullChanges kommentarlos die Serverversion. Diese Strategie eignet sich besonders für Daten mit Geschäftslogik-Validierung, bei der ein Client theoretisch invalide Zustände erzeugen könnte, die nur der Server zuverlässig erkennt.

Für granularere Anforderungen implementieren manche Backends Feld-Level-Merges, bei denen nicht der gesamte Datensatz, sondern nur die tatsächlich widersprüchlichen Felder aufgelöst werden, während unveränderte Felder aus beiden Versionen erhalten bleiben. Das erfordert deutlich mehr Backend-Logik als last-write-wins, reduziert aber die Zahl der Fälle, in denen legitime Änderungen eines Nutzers durch die Konfliktauflösung stillschweigend verloren gehen.

8. Performance bei großen Datenmengen

Lazy Loading ist der wichtigste Hebel, um WatermelonDB auch bei sehr großen lokalen Datenmengen performant zu halten. Da Queries erst beim tatsächlichen Zugriff materialisiert werden, kann eine Tabelle mit einer Million Zeilen genauso schnell initialisiert werden wie eine leere Tabelle, denn beim App-Start wird nichts geladen, das nicht direkt sichtbar oder abonniert ist. Erst wenn eine Komponente eine konkrete Query beobachtet, liest WatermelonDB die relevanten Zeilen aus SQLite.

Indizierung über isIndexed in der Schema-Definition ist bei Fremdschlüsseln und häufig gefilterten Spalten unverzichtbar. Ohne Index degeneriert eine Query wie Q.where('project_id', someId) auf einer großen Tabelle zu einem vollständigen Tabellenscan, was bei zehntausenden Zeilen spürbar Zeit kostet. Mit Index nutzt SQLite einen B-Tree-Lookup, der auch bei wachsender Datenmenge nahezu konstante Zugriffszeit behält.

Für Bulk-Operationen bietet WatermelonDB batch(), mit dem mehrere Create-, Update- oder Delete-Operationen in einer einzigen SQLite-Transaktion zusammengefasst werden. Statt tausend einzelne database.write()-Aufrufe mit jeweils eigenem Transaktions-Overhead auszuführen, reduziert ein einzelner batch()-Aufruf mit tausend Operationen die Schreiblast auf eine einzige Transaktion, was den Import großer Datensätze von Minuten auf Sekunden verkürzen kann.

9. WatermelonDB im Vergleich zu Realm und AsyncStorage

Die Wahl der lokalen Datenschicht hat direkte Auswirkungen auf Reaktivität, Query-Performance und den Aufwand für Offline-Sync. AsyncStorage war lange die Standardlösung für einfache Key-Value-Daten, stößt aber bei relationalen Daten und komplexen Queries schnell an Grenzen, weil jede Abfrage den kompletten Datensatz deserialisieren muss. Realm bietet native Objektpersistenz mit guter Performance, während WatermelonDB durch SQLite-Unterbau, Lazy Loading und eingebaute Sync-Primitive gezielt auf Offline-First-Anwendungsfälle mit großen Datenmengen zugeschnitten ist.

Kriterium WatermelonDB Realm AsyncStorage
Query-Performance Lazy Loading, SQLite-Index, gut bei Millionen Zeilen Native Objektpersistenz, sehr gut Volle Deserialisierung je Query, schwach ab 10.000+ Einträgen
Reaktivität Observables/RxJS nativ eingebaut Eigene Listener-API, weniger React-nativ Keine, manuelles Re-Fetching nötig
Sync-Unterstützung synchronize() mit pull/push eingebaut Realm Sync, kostenpflichtiger Cloud-Dienst Keine, komplett eigenbau nötig
Lernkurve Schema und Decorators, mittel ORM-ähnliches Objektmodell, mittel Sehr niedrig, reines Key-Value
Speichergrenzen SQLite-Limits, praktisch mehrere GB Mehrere GB, native Engine Ca. 6 MB unter Android ohne Anpassung

In der Praxis entscheidet sich die Wahl selten allein an der Rohgeschwindigkeit, sondern an der Sync-Anforderung. Wer eine App ohne Offline-Synchronisation baut, kann mit AsyncStorage für einfache Einstellungen auskommen. Sobald relationale Daten, große Listen und ein eigenes Backend mit bidirektionaler Synchronisation ins Spiel kommen, liefert WatermelonDB die passenderen Bausteine, ohne dass ein Team die Sync-Logik komplett selbst entwerfen muss.

Mironsoft

React Native, Offline-First-Architektur und mobile App-Entwicklung

React-Native-App, die auch offline zuverlässig funktioniert?

Wir bauen Offline-First-Architekturen mit WatermelonDB, inklusive Schema-Design, Sync-Engine gegen euer Backend und Konfliktlösung, die auch bei großen Datenmengen performant bleibt.

Architektur-Review

Bestehende Datenschicht analysieren und Offline-First-Lücken identifizieren

WatermelonDB-Setup

Schema, Modelle und Sync-Engine gegen euer bestehendes Backend implementieren

Performance-Tuning

Indizierung, Batch-Writes und reaktive Queries für große Datenmengen optimieren

10. Zusammenfassung

Offline-First mit WatermelonDB lösst dasselbe Grundproblem, das viele React-Native-Apps in der Praxis unterschätzen: Nutzer erwarten sofortige Reaktion, unabhängig von der Netzqualität am jeweiligen Standort. Der SQLite-Unterbau mit JSI-Bridge macht lokale Lese- und Schreiboperationen schnell genug, um jede Nutzerinteraktion ohne spürbare Verzögerung zu beantworten. Lazy Loading hält auch sehr große Tabellen performant, weil nur tatsächlich beobachtete Daten materialisiert werden. Decorators wie @field, @relation und @children reduzieren den Boilerplate-Aufwand für Model-Definitionen erheblich.

Die eingebaute synchronize()-Funktion mit pullChanges und pushChanges übernimmt die komplette Delta-Synchronisation gegen ein eigenes Backend, während die Konfliktstrategie, etwa last-write-wins oder Server-Autorität, bewusst der Anwendung überlassen bleibt. In Kombination mit withObservables entsteht eine reaktive Architektur, in der sich die Oberfläche automatisch aktualisiert, sobald sich lokale Daten ändern, ganz ohne zusätzliches State-Management. Wer eine mobile App mit echten Offline-Anforderungen baut, bekommt mit WatermelonDB ein Fundament, das SQLite-Performance, Reaktivität und Sync-Infrastruktur in einem Paket vereint.

Offline-First mit WatermelonDB, das Wichtigste auf einen Blick

Architektur

SQLite-Unterbau mit JSI-Bridge, Lazy Loading und RxJS-Observables als Fundament für Offline-First-Apps.

Schema und Modelle

appSchema() und tableSchema() definieren Tabellen, Decorators wie @field und @relation binden Model-Properties an SQLite-Spalten.

Sync und Konfliktlösung

synchronize() mit pullChanges/pushChanges überträgt nur das Delta. Last-write-wins oder Server-Autorität lösen Konflikte im Backend.

Performance

Indizierung mit isIndexed, Bulk-Schreiben mit batch() und lokal auf Millionen Zeilen skalierendes Lazy Loading.

11. FAQ: Offline-First mit WatermelonDB

1Was ist WatermelonDB?
Eine reaktive Datenbank für React Native mit SQLite-Unterbau, Lazy Loading, Observables und eingebauter Sync-Engine für Offline-First-Apps.
2Warum schneller als AsyncStorage?
AsyncStorage deserialisiert bei jeder Abfrage alles. WatermelonDB nutzt SQLite-Indizes und Lazy Loading, sodass nur benötigte Zeilen gelesen werden.
3Wie funktioniert die Sync mit eigenem Backend?
Über synchronize() mit pullChanges und pushChanges. pullChanges holt Serveränderungen, pushChanges überträgt lokale Änderungen aus dem Change-Log.
4Was passiert bei einem Sync-Konflikt?
WatermelonDB entscheidet nicht selbst. Üblich sind last-write-wins per Zeitstempel, Server-Autorität oder Feld-Level-Merges im Backend.
5Brauche ich Redux zusätzlich?
Meist nicht. withObservables verbindet Komponenten direkt mit Queries und aktualisiert die Oberfläche automatisch bei Datenänderungen.
6Wie migriere ich das Schema?
version-Nummer in appSchema() erhöhen und Migration über schemaMigrations() registrieren. Ohne Migration bricht die App beim Start ab.
7Funktioniert WatermelonDB mit Expo?
Nur mit Development Builds oder Bare Workflow. Im Managed Expo Go Workflow ohne Custom Native Code ist der native Adapter nicht verfügbar.
8Wie viele Datensätze verkraftet WatermelonDB?
Praktisch mehrere Millionen Zeilen pro Tabelle mit indizierten Fremdschlüsseln, da Lazy Loading nur beobachtete Daten materialisiert.
9@field vs. @relation?
@field bindet eine einfache Spalte an eine Property. @relation liefert ein verknüpftes Model-Objekt über eine Fremdschlüssel-Spalte.
10Wie teste ich die Sync-Engine ohne Backend?
Mit einem Mock-Server für pullChanges/pushChanges oder In-Memory-Fixtures, die typische Konfliktfälle nachbilden, vor dem realen Backend-Test.