Skip to content
·6 min read

Offline-First React Native With WatermelonDB (The 2026 Setup)

AsyncStorage breaks past 100 records. Realm is heavy. WatermelonDB is the answer for serious offline-first React Native apps. Here's the actual setup.

React NativeWatermelonDBOffline-FirstMobile

Every React Native app hits the same wall: you ship with AsyncStorage or MMKV, things work for the first 100 users, then someone with 5,000 records opens the app and it takes six seconds to load.

That wall is the storage layer. AsyncStorage is a key-value store, fine for tokens and preferences, broken for any real data. SQLite is fast but verbose. Realm is powerful but heavy.

WatermelonDB is the answer for serious offline-first apps. It's SQLite under the hood, lazy-loaded, observable, and ships with a sync protocol. Here's the actual setup.

Why WatermelonDB (and not the alternatives)

OptionGood forProblem
AsyncStorageTokens, prefsReads block the JS thread past a few hundred items
MMKVFast key-valueSame limitations as AsyncStorage, no queries
SQLite (raw)AnythingNo ORM, no observables, you write sync yourself
RealmAnythingHeavy native binary, sync costs money, lock-in
WatermelonDBOffline-first appsLearning curve, you write sync (but the protocol is spec'd)

WatermelonDB is built for the case where your data outgrows key-value storage and you need real offline sync. It lazy-loads records, a list of 10,000 items renders instantly because only the visible rows are loaded into JS memory.

Step 1: Install and configure

npm install @nozbe/watermelondb
npm install --save-dev @babel/plugin-proposal-decorators

Add to babel.config.js:

module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: [
    ['@babel/plugin-proposal-decorators', { legacy: true }],
    ['@babel/plugin-proposal-class-properties', { loose: true }],
  ],
}

The decorators plugin is required, WatermelonDB models use @field and @relation decorators.

Step 2: Define the schema

// app/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb'
 
export const schemas = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'projects',
      columns: [
        { name: 'name', type: 'string' },
        { name: 'created_at', type: 'number' },
        { name: 'is_archived', type: 'boolean' },
      ],
    }),
    tableSchema({
      name: 'tasks',
      columns: [
        { name: 'project_id', type: 'string', isIndexed: true },
        { name: 'title', type: 'string' },
        { name: 'completed_at', type: 'number' },
      ],
    }),
  ],
})

Bump version every time you change the schema. WatermelonDB runs migrations automatically based on the version diff.

Step 3: Define models

// app/models/Project.ts
import { Model } from '@nozbe/watermelondb'
import { field, relation, children } from '@nozbe/watermelondb/decorators'
 
export default class Project extends Model {
  static table = 'projects'
  static associations = {
    tasks: { type: 'has_many', foreignKey: 'project_id' },
  }
 
  @field('name') name!: string
  @field('created_at') createdAt!: number
  @field('is_archived') isArchived!: boolean
 
  @children('tasks') tasks!: any
}
// app/models/Task.ts
import { Model } from '@nozbe/watermelondb'
import { field, relation } from '@nozbe/watermelondb/decorators'
 
export default class Task extends Model {
  static table = 'tasks'
 
  @field('project_id') projectId!: string
  @field('title') title!: string
  @field('completed_at') completedAt!: number
 
  @relation('projects', 'project_id') project!: any
}

Step 4: Read with observables

This is the magic. No useEffect, no manual refetch, no Redux.

import { Database } from '@nozbe/watermelondb'
import { withObservables } from '@nozbe/with-observables'
 
const ProjectList = ({ projects }) => (
  <FlatList
    data={projects}
    keyExtractor={item => item.id}
    renderItem={({ item }) => <Text>{item.name}</Text>}
  />
)
 
const enhance = withObservables([], ({ database }) => ({
  projects: database.collections.get('projects').query().observe(),
}))
 
const ConnectedProjectList = enhance(ProjectList)

When a project is added, updated, or deleted anywhere in the app, the list re-renders. No event bus. No state management library.

Step 5: Write locally

await database.write(async () => {
  await database.collections.get('projects').create(project => {
    project.name = 'New Project'
    project.createdAt = Date.now()
    project.isArchived = false
  })
})

All writes happen inside database.write(). They're atomic and synchronous from the user's perspective, the UI updates instantly. Sync happens in the background.

Step 6: Sync with your backend

WatermelonDB ships a sync protocol. You provide two endpoints.

import { sync } from '@nozbe/watermelondb/sync'
 
async function syncWithBackend(database) {
  await sync({
    database,
    pullChanges: async ({ lastPulledAt }) => {
      const response = await fetch(`/api/sync?last_pulled_at=${lastPulledAt}`)
      return response.json()
    },
    pushChanges: async ({ changes, lastPulledAt }) => {
      await fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify({ changes }),
      })
    },
  })
}

The pull endpoint returns all changes on the server since lastPulledAt. The push endpoint accepts the client's local changes.

The shape of changes:

{
  "projects": {
    "created": [{ "id": "...", "name": "...", ... }],
    "updated": [{ "id": "...", "name": "...", ... }],
    "deleted": ["id1", "id2"]
  },
  "tasks": { ... }
}

This is the protocol, your backend implements it. Supabase works great. So does any Postgres + Node setup.

Conflict resolution

By default, WatermelonDB uses server-side last-write-wins. The server resolves conflicts based on updated_at timestamps.

For critical fields (counts, balances), write a custom resolver. The pattern: server returns a resolver function per field, your sync endpoint calls it.

99% of apps are fine with last-write-wins. Don't over-engineer this.

When WatermelonDB is overkill

Don't use it for:

  • Token and preference storage (use MMKV)
  • A list of items under 100 (use MMKV or AsyncStorage)
  • Apps that don't need offline mode (use a regular API + React Query)

Use it when:

  • Users create data offline
  • Lists grow past a few hundred items
  • You need cross-device sync
  • You're tired of writing manual cache invalidation

Offline-first is not a feature you bolt on later. It's an architecture decision. WatermelonDB makes it bearable, but only if you commit to the model from day one.

Want help building an offline-first React Native app?

I build offline-first mobile apps with WatermelonDB, sync, and conflict resolution done right. Let's talk.