Building Offline-First Apps with WatermelonDB
AI generated
RN
native
React Native · Offline-First · WatermelonDB · Mobile Apps
Building Offline-First Apps with WatermelonDB
from SQLite schema to a working sync engine

Teams that build mobile apps without an offline-first architecture lose users at every dead zone. WatermelonDB combines a SQLite foundation, lazy loading and reactive observables with a built-in sync engine, so React Native apps respond instantly on the device while reliably synchronizing with the server in the background.

18 min read appSchema · decorators · synchronize() · observables React Native · WatermelonDB 0.27 · SQLite · JSI

1. What offline-first means and why mobile apps need it

Offline-first means an app treats a local, fully functional storage system as its primary data source and treats the backend only as a background synchronization target. For React Native applications, this is not a nice-to-have, it is a baseline requirement the moment users work with the app on the subway, abroad without data roaming, or inside buildings with weak reception. WatermelonDB was built exactly for this scenario: every read and write operation runs against the local SQLite database, never directly against a server, and synchronization runs as a separate, asynchronous background process.

The difference from a classic client server app shows up immediately in perceived speed. While a network driven app waits for a server response on every tap of a button, an offline-first app built with WatermelonDB responds within a few milliseconds because the request never leaves the device. That is exactly the response speed users expect from every mobile app today, regardless of the actual network quality at their current location.

A third aspect concerns data loss and consistency. Without an offline-first architecture, a dropped connection during a save often leads to inconsistent state or lost input. WatermelonDB addresses this by writing every change to SQLite immediately and permanently, while an internal change log tracks in the background which records still need to be synchronized to the server.

2. WatermelonDB architecture at a glance

Under the hood, WatermelonDB relies on a native SQLite database per platform, addressed directly from the JavaScript thread through a JSI bridge (JavaScript Interface), bypassing the classic asynchronous bridge detour through JSON serialization. This JSI connection is the decisive performance difference compared to older solutions: queries run at native level, while the actual data processing still happens asynchronously in the JavaScript layer.

The second core architectural principle is lazy loading. A WatermelonDB query does not immediately return an array of objects, it returns a query object that only loads data on actual access, for example through fetch() or as an observable. This makes it possible to materialize only the rows actually visible in a list of ten thousand records, instead of loading the entire table into memory when the app starts.

The third principle is observables built on RxJS. Every query can be subscribed to as an observable stream that automatically emits a new value on every relevant data change. Combined with withObservables in React components, this produces a reactive system in which the interface updates itself without manual reloading or Redux-like state management, as soon as the underlying WatermelonDB records change.

3. Setup and schema definition

Installing WatermelonDB happens through npm, followed by setting up the Babel plugin for decorators, which is needed for the model definitions in the next section. On iOS, a pod install is additionally required, since WatermelonDB ships a native SQLite adapter that gets linked as a CocoaPod. On Android, the native adapter is accessed directly through JSI, without extra Gradle configuration in most 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"
  }
}

The schema is defined centrally through appSchema() and describes every table with tableSchema(), including all columns and their types. WatermelonDB supports the types string, number and boolean for columns. More complex structures such as nested objects are either moved into their own tables with relations, or stored as a JSON string inside a string column. isIndexed matters on foreign key columns like project_id, so relation queries stay performant even when a table holds several hundred thousand rows.


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' },
      ],
    }),
  ],
})

Every schema change increments the version number and requires a migration, which WatermelonDB provides through schemaMigrations(). Without a registered migration, the app crashes on the next launch as soon as a user with an older schema installs the new version. This migration requirement feels inconvenient at first, but it prevents exactly the kind of silent schema inconsistency that unplanned SQLite changes would otherwise turn into crashes in the field.

4. Models and decorators

Every table gets a corresponding model class that extends Model from @nozbe/watermelondb. The static table property points to the table name from the schema, and associations describes the relationship types belongs_to and has_many to other models. Inside the class, decorators such as @field, @relation, @children and @date expose the underlying SQLite columns as regular JavaScript properties, including automatic type conversion between raw SQLite values and JavaScript types.

The @field decorator binds a simple column directly to a property. Reads return the current value, writes must happen inside a database.write() block, since WatermelonDB wraps every mutation in a transaction. The @relation decorator returns a single linked model object on access, for example the Project belonging to a Task through project_id, while @children returns a query representing all linked child records, for example every Task belonging to a Project.


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
}

The @readonly decorator combined with @date marks fields such as createdAt and updatedAt as framework managed: WatermelonDB updates updatedAt automatically on every change to the record, so application logic never has to maintain the field itself. This combination of decorators significantly reduces boilerplate compared to a manual SQLite wrapper, where every column access would need to be written by hand.

5. Implementing a sync engine

The core of any offline-first architecture with WatermelonDB is the synchronize() function from @nozbe/watermelondb/sync, which expects two callbacks: pullChanges and pushChanges. pullChanges receives the timestamp of the last successful synchronization (lastPulledAt) and must fetch from the server every record changed, created or deleted since then, grouped by table into a changes object with the keys created, updated and deleted.

pushChanges, in turn, receives exactly the local changes that accumulated in WatermelonDB since the last sync cycle, and must transmit them to the backend. WatermelonDB internally keeps a change log that tracks every mutation since the last successful push, so pushChanges never transmits the entire local data set, only the delta. That keeps sync cycles lean even with large data volumes and noticeably reduces backend load compared to a naive full sync approach.


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,
  })
}

The synchronize() function itself encapsulates transaction safety: if pushChanges fails, for example through a network drop in the middle of a request, the local changes remain in the change log and get transmitted again on the next call. A migrationsEnabledAtVersion parameter additionally ensures schema migrations are correctly embedded into the sync protocol cycle whenever the local schema has changed since the last sync.

6. Reactive queries with observables in components

withObservables from @nozbe/with-observables connects a React component to one or more WatermelonDB observables and makes the component re-render automatically whenever the observed data changes, without needing Redux, Context or manual refetching. The typical call withObservables(['task'], (props) => ({ task: props.task.observe() })) subscribes to exactly one Task record. If its title or status changes anywhere in the app, every component observing that Task updates almost instantly.

For lists, you instead use tasksCollection.query(Q.where('is_completed', false)).observe(), which returns an array observable that re-emits on every insert, update or delete affecting the query condition. Because WatermelonDB lazily loads query results, this reactive coupling stays performant even with lists holding thousands of entries, since only the difference between the old and new result set actually gets re-rendered, similar to how React itself diffs at the data level.

An important practical note: withObservables should sit as low as possible in the component tree, close to the actual display, rather than wrapping an entire screen component. That way, a single change to a Task does not trigger a re-render of an entire list, only of the affected row, which is exactly what makes the difference between a smooth and a stuttering offline-first app in long, scrollable lists.

7. Conflict resolution during synchronization

As soon as multiple devices, or a device and a server, can change the same record offline, conflicts inevitably arise that a sync engine must resolve. WatermelonDB itself makes no decision about the conflict strategy, deliberately leaving it to the backend implementation of pullChanges and pushChanges. The most common strategy is last-write-wins based on updatedAt timestamps: the record with the newer timestamp wins, the older one gets discarded.

Alternatively, many teams rely on server authority: the server always decides, regardless of timestamp, which version counts as canonical, and the client silently adopts the server version on the next pullChanges. This strategy works especially well for data with business logic validation, where a client could theoretically produce an invalid state that only the server can reliably detect.

For more granular requirements, some backends implement field level merges, where not the entire record but only the actually conflicting fields get resolved, while unchanged fields from both versions are preserved. This requires considerably more backend logic than last-write-wins, but reduces the number of cases where a user's legitimate change silently gets lost during conflict resolution.

8. Performance with large data sets

Lazy loading is the most important lever for keeping WatermelonDB performant even with very large local data volumes. Since queries are only materialized on actual access, a table with a million rows can initialize just as fast as an empty table, because nothing gets loaded at app start that is not directly visible or subscribed to. Only once a component observes a concrete query does WatermelonDB read the relevant rows from SQLite.

Indexing through isIndexed in the schema definition is essential on foreign keys and frequently filtered columns. Without an index, a query such as Q.where('project_id', someId) degenerates into a full table scan on a large table, which noticeably costs time once you reach tens of thousands of rows. With an index, SQLite uses a B-tree lookup that stays close to constant access time even as data volume grows.

For bulk operations, WatermelonDB offers batch(), which groups multiple create, update or delete operations into a single SQLite transaction. Instead of running a thousand individual database.write() calls, each with its own transaction overhead, a single batch() call with a thousand operations reduces the write load to one transaction, which can shorten the import of large data sets from minutes to seconds.

9. WatermelonDB compared to Realm and AsyncStorage

Choosing the local data layer has a direct effect on reactivity, query performance and the effort required for offline sync. AsyncStorage was long the default solution for simple key value data, but quickly hits its limits with relational data and complex queries, since every query has to deserialize the entire stored value. Realm offers native object persistence with good performance, while WatermelonDB, through its SQLite foundation, lazy loading and built-in sync primitives, is purpose built for offline-first use cases with large data volumes.

Criterion WatermelonDB Realm AsyncStorage
Query performance Lazy loading, SQLite indexes, good with millions of rows Native object persistence, very good Full deserialization per query, weak beyond 10,000+ entries
Reactivity Observables/RxJS built in natively Own listener API, less React-native None, manual re-fetching required
Sync support synchronize() with pull/push built in Realm Sync, a paid cloud service None, entirely custom built
Learning curve Schema and decorators, moderate ORM-like object model, moderate Very low, plain key value
Storage limits SQLite limits, practically several GB Several GB, native engine Around 6 MB on Android without adjustment

In practice, the choice rarely comes down to raw speed alone, it comes down to the sync requirement. A team building an app without offline synchronization can get by with AsyncStorage for simple settings. Once relational data, large lists and a custom backend with bidirectional synchronization enter the picture, WatermelonDB delivers the more fitting building blocks, without a team having to design the sync logic entirely from scratch.

Mironsoft

React Native, offline-first architecture and mobile app development

A React Native app that stays reliable offline too?

We build offline-first architectures with WatermelonDB, including schema design, a sync engine against your backend and conflict resolution that stays performant even with large data volumes.

Architecture review

Analyze your existing data layer and identify offline-first gaps

WatermelonDB setup

Implement schema, models and a sync engine against your existing backend

Performance tuning

Optimize indexing, batch writes and reactive queries for large data sets

10. Summary

Offline-first with WatermelonDB solves the same core problem that many React Native apps underestimate in practice: users expect instant response, regardless of network quality at their current location. The SQLite foundation with a JSI bridge makes local read and write operations fast enough to answer every user interaction without noticeable delay. Lazy loading keeps even very large tables performant, since only actually observed data gets materialized. Decorators such as @field, @relation and @children significantly reduce the boilerplate effort for model definitions.

The built-in synchronize() function with pullChanges and pushChanges handles the entire delta synchronization against a custom backend, while the conflict strategy, such as last-write-wins or server authority, is deliberately left to the application. Combined with withObservables, this produces a reactive architecture in which the interface updates itself automatically whenever local data changes, with no additional state management required. Teams building a mobile app with genuine offline requirements get, with WatermelonDB, a foundation that unites SQLite performance, reactivity and sync infrastructure in one package.

Offline-first with WatermelonDB, the essentials at a glance

Architecture

SQLite foundation with a JSI bridge, lazy loading and RxJS observables as the basis for offline-first apps.

Schema and models

appSchema() and tableSchema() define tables, decorators such as @field and @relation bind model properties to SQLite columns.

Sync and conflict resolution

synchronize() with pullChanges/pushChanges transmits only the delta. Last-write-wins or server authority resolve conflicts in the backend.

Performance

Indexing with isIndexed, bulk writes with batch(), and lazy loading that scales locally to millions of rows.

11. FAQ: Offline-First with WatermelonDB

1What is WatermelonDB?
A reactive database for React Native with a SQLite foundation, lazy loading, observables and a built-in sync engine for offline-first apps.
2Why faster than AsyncStorage?
AsyncStorage deserializes everything on every query. WatermelonDB uses SQLite indexes and lazy loading, so only needed rows get read.
3How does sync with a custom backend work?
Through synchronize() with pullChanges and pushChanges. pullChanges fetches server changes, pushChanges transmits local changes from the change log.
4What happens during a sync conflict?
WatermelonDB does not decide itself. Common strategies are last-write-wins by timestamp, server authority, or field level merges in the backend.
5Do I need Redux as well?
Mostly not. withObservables connects components directly to queries and updates the interface automatically on data changes.
6How do I migrate the schema?
Increment the version number in appSchema() and register a migration through schemaMigrations(). Without a migration, the app crashes on launch.
7Does WatermelonDB work with Expo?
Only with development builds or the bare workflow. In the managed Expo Go workflow without custom native code, the native adapter is not available.
8How many records can WatermelonDB handle?
Practically several million rows per table with indexed foreign keys, since lazy loading only materializes observed data.
9@field vs. @relation?
@field binds a simple column to a property. @relation returns a linked model object through a foreign key column.
10How do I test the sync engine without a backend?
With a mock server for pullChanges/pushChanges or in-memory fixtures that reproduce typical conflict cases, before testing against the real backend.