Offline-First Sync With WatermelonDB And SQLite

Mobile Development
Date:November 2, 2026
Topic:
Offline-First Sync With WatermelonDB And SQLite
⏱ 3 min read

Your users don't care about your sync architecture. They care that the app works in a subway tunnel, on a plane, or when the cell signal dies at a conference. Offline-first isn't a feature. It's the baseline for retention.

Why WatermelonDB And SQLite Still Win In 2026

React Native developers have three real choices for local persistence: raw SQLite via expo-sqlite or react-native-quick-sqlite, PowerSync for a managed backend, or WatermelonDB. Raw SQL gives control but demands you write migration logic, conflict resolution, and a reactive query layer yourself. PowerSync removes backend ops but locks you into their cloud and pricing model. WatermelonDB sits in the sweet spot: a lazy, observable layer on top of SQLite that handles schema migrations, conflict-free replicated data types (CRDTs) for merge safety, and a JSI bridge that keeps the JS thread free.

typescript
// schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb'

export const schema = appSchema({
  version: 4,
  tables: [
    tableSchema({
      name: 'posts',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'body', type: 'string' },
        { name: 'author_id', type: 'string', isIndexed: true },
        { name: 'updated_at', type: 'number' },
        { name: 'deleted', type: 'boolean' },
      ],
    }),
    tableSchema({
      name: 'authors',
      columns: [
        { name: 'name', type: 'string' },
        { name: 'email', type: 'string', isIndexed: true },
      ],
    }),
  ],
})

Model Layer And Lazy Queries

Watermelon models map 1:1 to tables. The key difference from ORMs you know: queries are lazy. Calling database.get('posts').query(Q.where('author_id', authorId)) returns a Query object, not data. Data loads only when you subscribe or call fetch(). This keeps startup fast on low-end Android devices because you never hydrate the full table into memory.

typescript
// models/Post.ts
import { Model } from '@nozbe/watermelondb'
import { field, text, date, relation } from '@nozbe/watermelondb/decorators'

export default class Post extends Model {
  static table = 'posts'
  static associations = {
    authors: { type: 'belongs_to', key: 'author_id' },
  }

  @text('title') title!
  @text('body') body!
  @field('updated_at') updatedAt!
  @field('deleted') deleted!
  @relation('authors', 'author_id') author
}

Sync Strategy: Pull, Push, Resolve

Watermelon doesn't ship a sync server. You build the contract. The pattern that survives production:

1. Timestamp-based pull. Client sends last_pulled_at. Server returns rows where updated_at > last_pulled_at plus a tombstone list for deletes.

2. Optimistic push. Local writes mark _status: 'pending'. Background worker POSTs changes. On 2xx, mark synced. On 409, fetch server version, merge via CRDT, retry.

3. CRDT merge for text. Use Yjs or Automerge for collaborative fields. For scalar fields, last-writer-wins on updated_at with a client-generated UUID tiebreaker.

💡
TipStore the server's version vector (or simple integer) on each row. It makes conflict detection O(1) instead of O(n) diffing.

MMKV For Ephemeral State

SQLite is heavy for tiny, high-frequency keys: theme preference, onboarding step, last seen tutorial ID. MMKV uses JSI and mmap for sub-millisecond reads. Keep it separate from Watermelon.

typescript
// storage/mmkv.ts
import { MMKV } from 'react-native-mmkv'
export const ephemeral = new MMKV({ id: 'ephemeral' })

// usage
ephemeral.set('theme', 'dark')
const theme = ephemeral.getString('theme') ?? 'system'

Migration Reality Check

Watermelon migrations run on the SQLite thread via JSI. They are fast but irreversible. Test on a device with 2 GB RAM and a 50k-row database. Common failure: adding a non-null column without a default. Always provide default in the migration or backfill in a separate step.

typescript
// migrations/4_add_published_at.ts
import { Migration } from '@nozbe/watermelondb/Database'

export const migrate = async (database: Database) => {
  await database.adapter.execute(
    `ALTER TABLE posts ADD COLUMN published_at INTEGER DEFAULT 0`
  )
}

✦

Comparison At A Glance

DimensionWatermelonDBPowerSyncRaw SQLite
Setup timeMedium (schema + sync)Low (managed backend)High (build everything)Sync ownershipYouSharedYou
CRDT supportBuilt-in (via plugins)NativeManual
Bundle size~120 KB~200 KB (SDK)~50 KB
Offline query speedLazy, indexedLazy, indexedManual indexes
Backend lock-inNoneYesNone

Ship The Resilient Version

Start with WatermelonDB schema version 1. Add MMKV for UI state. Write a 200-line sync worker that pulls deltas every 30 seconds and pushes pending writes with exponential backoff. Test by killing the network mid-write. If the app recovers without data loss, you've earned the offline-first label.

⚠️
WarningNever trust client timestamps for ordering. Use a hybrid logical clock or server-assigned sequence on write acknowledgment.
Share𝕏 Twitterin LinkedInin Whatsapp