From MMKV queues to reactive SQLite โ what I learned engineering a production offline-first architecture that handles any scenario
โก TL;DR
- The MMKV + TanStack Query pattern is a perfectly valid offline strategy โ until you need relational queries, conflict resolution, or reactive UI updates on local data
- WatermelonDB on Expo SDK 54 (new arch mandatory, React 19) requires the community Expo plugin, peer dependency overrides, and specific native patches โ the official docs alone will not get you there
- PowerSync and ElectricSQL + TanStack DB are serious alternatives with different tradeoff profiles โ this guide compares all three from a full-stack perspective
- Every config file (
app.plugin.js,eas.json, Podfile patches) is included with exact version numbers โ no "left as an exercise for the reader"- Full working demo repo: github.com/FastheDeveloper/watermelondb-expo-offline-demo
๐ ๏ธ Prerequisites
Before diving in, make sure you have:
- Node.js 20+ (LTS)
-
Expo CLI (
npx expoโ no global install needed) -
EAS CLI (
npm install -g eas-cli) for building native binaries - Xcode 16+ (for iOS simulator builds on Apple Silicon)
- Android Studio with NDK installed (for Android builds)
- A working Expo SDK 54 project โ or willingness to create one
- Familiarity with TanStack Query โ this guide builds on top of it, not from scratch
Version matrix used in this article:
| Package | Version |
|---|---|
| Expo SDK | 54 |
| React Native | 0.81.5 |
| React | 19.1.0 |
| TypeScript | 5.9.x |
| @nozbe/watermelondb | 0.28.0 |
| @tanstack/react-query | 5.100.x |
| react-native-mmkv | 4.3.x |
| @powersync/react-native | 1.34.0 |
๐ Bookmark this table. You will come back to it when a peer dep error makes you question your life choices.
๐งต The Pattern That Got Me Here
I want to be upfront about where I started.
A couple of years ago, I was building a field operations app โ think workers in rural areas confirming tasks, uploading images, syncing data when they got back to signal. The offline requirement was clear: if you are offline, your work should not be lost.
Here is what I shipped:
// offlineStorage.ts โ the MMKV queue pattern
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
const OFFLINE_QUEUE_KEY = "offline_pending_mutations";
interface OfflineTask<T = Record<string, unknown>> {
id: number;
data: T;
timestamp: number;
}
export const queueOfflineMutation = <T extends Record<string, unknown>>(
task: OfflineTask<T>,
): void => {
const stored = storage.getString(OFFLINE_QUEUE_KEY);
const queue: OfflineTask<T>[] = stored ? JSON.parse(stored) : [];
queue.push(task);
storage.set(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
};
export const getOfflineQueue = <
T = Record<string, unknown>,
>(): OfflineTask<T>[] => {
const stored = storage.getString(OFFLINE_QUEUE_KEY);
return stored ? JSON.parse(stored) : [];
};
export const clearOfflineQueue = (): void => {
storage.delete(OFFLINE_QUEUE_KEY);
};
Paired with a TanStack Query persister that caches API responses in MMKV:
// queryClient.ts โ MMKV-backed TanStack Query persistence
import { QueryClient } from "@tanstack/react-query";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { MMKV } from "react-native-mmkv";
const mmkv = new MMKV();
const clientStorage = {
getItem: (key: string): string | null => mmkv.getString(key) ?? null,
setItem: (key: string, value: string): void => mmkv.set(key, value),
removeItem: (key: string): void => mmkv.delete(key),
};
export const persister = createSyncStoragePersister({
storage: clientStorage,
key: "TANSTACK_QUERY_OFFLINE_CACHE",
});
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24h cache retention
staleTime: 1000 * 60 * 5, // 5min freshness window
retry: 2,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
networkMode: "offlineFirst", // serve cache first, then revalidate
},
mutations: {
retry: 1,
networkMode: "offlineFirst",
},
},
});
And a sync hook that drains the queue when connectivity returns:
// useSyncOfflineQueue.ts
import { useEffect } from "react";
import { useNetInfo } from "./useNetInfo";
import { getOfflineQueue, clearOfflineQueue } from "../utils/offlineStorage";
export const useSyncOfflineQueue = (
authToken: string | null,
syncFn: (tasks: OfflineTask[]) => Promise<void>,
onSuccess?: () => void,
): void => {
const { isOnline } = useNetInfo();
useEffect(() => {
const sync = async (): Promise<void> => {
if (!isOnline || !authToken) return;
const queue = getOfflineQueue();
if (queue.length === 0) return;
try {
await syncFn(queue);
clearOfflineQueue();
onSuccess?.();
} catch (error) {
// Queue stays intact โ retry on next connectivity change
console.error("Offline sync failed, will retry:", error);
}
};
sync();
}, [isOnline, authToken]);
};
This worked. It shipped. It ran in production for tens of thousands of users. Workers could go offline, confirm tasks, attach photos, and everything synced when signal returned.
So why write about WatermelonDB?
๐ก Takeaway: The MMKV queue pattern is a legitimate offline strategy. If your app just needs to cache reads and queue writes, stop reading and use this. Seriously.
๐งฑ Where the Queue Pattern Hits Its Ceiling
The queue pattern did not fail me. I outgrew it by thinking about harder problems.
When I started designing for more demanding scenarios โ apps where users might be offline for hours, needing to browse historical data, filter records, handle multi-step workflows where step 3 depends on data from step 1 โ the cracks in the architecture became visible before I even hit them:
1. ๐ No relational queries offline.
MMKV stores flat JSON blobs. Want "all tasks for Zone A with species Oak planted after March"? You deserialize every record into memory and filter in JavaScript. With 100 records, nobody notices. With 10,000, your users notice.
2. ๐ฃ All-or-nothing sync.
The queue drains in one API call. If one task in a batch of 20 fails validation, nothing syncs. There is no per-record retry, no partial success tracking, no way to surface "18 synced, 2 need attention."
3. ๐ป Zero conflict detection.
If a task gets reassigned server-side while a worker is offline, the sync blindly pushes stale data. No version check, no lastPulledAt timestamp, no conflict resolution strategy. The server's truth and the client's truth diverge silently.
4. ๐ฅ๏ธ No reactive UI.
When offline data changes, nothing re-renders. You manually call refetchQueries() after sync. The UI and the data layer are completely decoupled โ fine for a cache, terrible for a database.
5. ๐ฒ No schema enforcement at the storage layer.
MMKV does not care what shape your data is. A typo in a field name is a runtime error you discover in production, not a compile-time error you catch in your editor.
Here is the mental model that clarified things for me:
Cache โ Queue โ Database โ Sync Engine
โ โ โ โ
โ โ โ โ
TanStack MMKV WatermelonDB PowerSync /
Query + queue (SQLite + ElectricSQL
persist observables)
Most developers stop at "Queue" and call it offline-first. Real offline-first starts at "Database."
๐ก Takeaway: Once you need to query your offline data โ not just store and forward it โ you need a database. And once you need a database that syncs, you need a sync strategy.
โ๏ธ The Landscape: WatermelonDB vs PowerSync vs ElectricSQL
Before the WatermelonDB setup, you deserve to see the full picture. These are the three solutions evaluated seriously, from a full-stack perspective with a mobile focus.
๐ WatermelonDB โ Full Control, Full Responsibility
WatermelonDB is a client-side reactive database built on SQLite, designed specifically for React Native apps with large datasets.
How it works: You define schemas and model classes. WatermelonDB creates SQLite tables, gives you observable queries (UI re-renders when data changes), and provides a sync protocol specification. You build the sync server yourself โ pull endpoint, push endpoint, conflict handling, all of it.
The good:
- โก Fastest client-side queries via JSI (C++ bridge, no serialization overhead)
- ๐ Observable/reactive โ
observe()on a query, UI updates automatically - ๐ค Lazy loading by default โ only loads records when accessed
- ๐ MIT licensed. Zero recurring cost. No vendor lock-in.
- ๐ญ Proven at scale โ Nozbe uses it in their own production apps
The reality (especially in 2026):
- โ ๏ธ
@nozbe/with-observablespeer deps do not include React 19 โ you need overrides - โ ๏ธ Untested on React Native's new architecture (now mandatory in Expo SDK 54)
- โ ๏ธ Community Expo plugin has not been updated for SDK 54
- โ ๏ธ Last stable release (0.28.0) was April 2025 โ over a year ago
- ๐๏ธ You build the entire sync backend. Every endpoint, every conflict handler, every retry.
Best for: Teams with backend engineers who want full control over data residency, sync logic, and zero vendor dependency. If you are building fintech or healthcare where you cannot send data through a third-party sync service, this is your option.
โก PowerSync โ Sync as a Service
PowerSync is an end-to-end sync engine that sits between your backend database and your clients.
How it works: PowerSync connects to your Postgres (or MongoDB/MySQL) via change data capture โ logical replication on Postgres, change streams on MongoDB. It streams filtered subsets of data to clients, where it lands in SQLite. Reads sync automatically. Writes go through your own backend API.
The good:
- ๐ซ No client-side schema migrations needed (schemaless SQLite views)
- ๐ฏ First-class Expo support with a dedicated config plugin (
@powersync/react-nativev1.34.0) - ๐ฆ Rust-based sync client enabled by default โ fast and reliable
- ๐ Multi-platform: React Native, Flutter, Kotlin, Swift, web
- ๐ง Actively maintained โ releases in May 2026
The tradeoffs:
- ๐ฐ $49/month minimum for production use (free tier deactivates after 1 week of inactivity)
- ๐ Source-available license, not truly open source
- ๐๏ธ You still build write endpoints โ it is not a complete backend replacement
Best for: Teams on Postgres/MongoDB who want sync solved, not built. If you would rather configure than code, start here.
๐ฟ ElectricSQL + TanStack DB โ The Open-Source Middle Ground
ElectricSQL is a read-path sync engine from Postgres to clients. TanStack DB layers client-side persistence, live queries, and optimistic mutations on top.
The good:
- ๐ Apache 2.0 licensed โ genuinely open source
- ๐ธ Generous pricing: ~5M writes/month free, then $1/million
- ๐ค Deep TanStack ecosystem integration
- ๐ Active development โ
@electric-sql/clientv1.5.16 released May 2026
The tradeoffs:
- ๐ TanStack DB on React Native is newer โ less battle-tested than WatermelonDB's years of production use
- ๐ Two libraries to coordinate (Electric + TanStack DB) vs one
Best for: Teams already in the TanStack ecosystem who want an open-source path.
๐ The Comparison Table
| WatermelonDB | PowerSync | ElectricSQL + TanStack DB | |
|---|---|---|---|
| Client DB | SQLite (JSI) | SQLite | SQLite (expo-sqlite) |
| Server component | You build it | Managed service | Electric Cloud + your API |
| Sync direction | Push/pull (you implement) | Auto reads, writes via API | Auto reads, writes via API |
| Conflict resolution | Reject-on-conflict | Server-side LWW (customizable) | Your backend's responsibility |
| Expo SDK 54 ready | Needs patches + overrides | โ Yes (first-class) | โ Yes |
| React 19 support | Peer dep issues | โ Yes | โ Yes |
| New Architecture | Untested officially | โ Supported | โ Supported |
| License | MIT | Source-available | Apache 2.0 |
| Price | Free | From $49/mo | ~Free under 5M writes/mo |
| You build | Everything | Write endpoints only | Write endpoints only |
๐ Honorable Mentions
Jazz.tools โ CRDT-based local-first framework with first-class Expo support. Ships with auth, permissions, real-time collaboration, and E2E encryption out of the box. If your app needs multiplayer/collaboration features, evaluate this before building from scratch on WatermelonDB.
Legend State โ State management with built-in persistence and sync. Think Zustand + offline persistence + sync plugin in one. Good for simpler offline needs without a separate database layer.
RxDB โ Reactive NoSQL database with pluggable storage and replication. More flexible backend compatibility than WatermelonDB, but less optimized for React Native specifically.
Realm โ MongoDB's on-device database. โ Atlas Device Sync was deprecated in September 2024. Do not start new projects on it for sync.
๐ก Takeaway: WatermelonDB gives you the most control but the most work. PowerSync gives you the least work but the most cost. ElectricSQL + TanStack DB is the open-source middle ground. Pick based on your team's backend capacity, budget, and how much you want to own vs rent.
๐ค Why I Chose WatermelonDB (and When I Would Choose Differently)
For the production app where I needed this, the decision came down to four factors:
๐ Data residency requirements. Fintech. Could not route read sync through a third-party service. WatermelonDB's client-only architecture with a self-built sync server kept all data in our infrastructure.
๐ฅ Existing backend team. We had a NestJS + PostgreSQL backend with engineers who could implement sync endpoints. Building pull/push was not a burden โ it was Tuesday.
๐ Offline query volume. Field workers needed to browse, filter, and search through thousands of records with no signal. SQLite with indexed columns was non-negotiable.
๐ฐ Zero recurring cost. MIT license. No per-seat, per-sync, per-month charges. When you are deploying to thousands of devices, this matters.
Here is the honest version though: if I were starting a new project today with a Postgres backend and a small team (2-3 devs), I would evaluate PowerSync first. The $49/month is nothing compared to the engineering hours you burn building and maintaining a sync server. If I needed open source and was already using TanStack Query, I would go ElectricSQL + TanStack DB.
I chose WatermelonDB because I needed full control โ and I was willing to fight the setup.
Let me show you that fight. ๐ฅ
๐ Hey โ you are still here. That means you are serious about offline-first and not just looking for a five-minute fix. That puts you ahead of most developers who copy-paste a TanStack Query persister and call it a day. The next section is the one that does not exist anywhere else on the internet. Let's go.
๐ง The Setup That Actually Works: WatermelonDB + Expo SDK 54
This is the section that does not exist anywhere else. Not in the WatermelonDB docs (which target vanilla React Native). Not in the Expo docs (which do not mention WatermelonDB). Not in any single blog post, tutorial, or Stack Overflow answer.
This is every file, every patch, every workaround โ verified on Expo SDK 54 with new architecture (mandatory) and React 19.
๐ฆ Installation โ The Exact Commands
Start with a fresh Expo SDK 54 project or an existing one:
# Create a new project (if starting fresh)
npx create-expo-app@latest my-offline-app --template default
cd my-offline-app
Install WatermelonDB and its peer dependencies:
# Core WatermelonDB
npx expo install @nozbe/watermelondb
# Observable integration for React
npm install @nozbe/with-observables
# Community Expo config plugin (required for native module linking)
npm install @morrowdigital/watermelondb-expo-plugin@2.4.0-beta.0
๐ค Why
@morrowdigitaland not@lovesworking? The@morrowdigitalplugin has a beta release (2.4.0-beta.0) tested against SDK 54. The@lovesworkingfork targets SDK 52/53 specifically. For SDK 54,@morrowdigitalis the starting point โ and exact patches are covered below. Neither plugin has an official SDK 54 release at time of writing, so expect to troubleshoot. That is exactly why this guide exists.
๐ฉน Fixing the React 19 Peer Dependency
@nozbe/with-observables@1.6.0 declares react@^16||^17||^18 as a peer dependency. React 19 is not in that range. Two options:
Option A: package.json overrides (recommended โ
)
{
"overrides": {
"@nozbe/with-observables": {
"react": "$react"
}
}
}
For Yarn users, use resolutions instead of overrides.
Option B: --legacy-peer-deps
npm install --legacy-peer-deps
This works but silences all peer dependency warnings, which can mask real issues. Option A is surgical precision.
๐ The Expo Config Plugin
Add the WatermelonDB plugin to your app.json (or app.config.ts):
{
"expo": {
"plugins": [
[
"@morrowdigital/watermelondb-expo-plugin",
{
"disableJsi": false
}
]
]
}
}
โก
disableJsi: falseenables the JSI (JavaScript Interface) adapter โ the C++ bridge that makes WatermelonDB queries up to 3x faster than the bridge-based adapter. With new architecture being mandatory in SDK 54, JSI is the expected path. If you hit JSI-related crashes during development, set this totruetemporarily to isolate the issue.
If the plugin does not work out of the box with SDK 54, you may need a custom config plugin. Here is a minimal one:
// plugins/withWatermelonDB.ts
import { ConfigPlugin, withDangerousMod } from "expo/config-plugins";
import * as fs from "fs";
import * as path from "path";
const withWatermelonDBAndroid: ConfigPlugin = (config) => {
return withDangerousMod(config, [
"android",
async (cfg) => {
const buildGradlePath = path.join(
cfg.modRequest.platformProjectRoot,
"app",
"build.gradle",
);
let buildGradle = fs.readFileSync(buildGradlePath, "utf-8");
if (!buildGradle.includes("watermelondb-jsi")) {
const jsiDep = `
// WatermelonDB JSI
implementation project(':watermelondb-jsi')`;
buildGradle = buildGradle.replace(
/dependencies\s*\{/,
`dependencies {${jsiDep}`,
);
fs.writeFileSync(buildGradlePath, buildGradle);
}
return cfg;
},
]);
};
export default withWatermelonDBAndroid;
Then reference it in your config:
{
"expo": {
"plugins": ["./plugins/withWatermelonDB"]
}
}
๐๏ธ Schema Definition
WatermelonDB schemas are defined in code, not migration files. This is your source of truth:
// src/database/schema.ts
import { appSchema, tableSchema } from "@nozbe/watermelondb";
// Schema version โ increment when you add/modify tables or columns
const SCHEMA_VERSION = 1;
export const schema = appSchema({
version: SCHEMA_VERSION,
tables: [
tableSchema({
name: "tasks",
columns: [
{ name: "server_id", type: "number", isIndexed: true },
{ name: "title", type: "string" },
{ name: "description", type: "string" },
{ name: "status", type: "string", isIndexed: true },
{ name: "task_type", type: "string" },
{ name: "due_date", type: "number" }, // stored as timestamp
{ name: "location_name", type: "string" },
{ name: "latitude", type: "number", isOptional: true },
{ name: "longitude", type: "number", isOptional: true },
{ name: "is_synced", type: "boolean" }, // local sync tracking
{ name: "last_modified", type: "number" }, // server timestamp
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
tableSchema({
name: "task_confirmations",
columns: [
{ name: "task_id", type: "string", isIndexed: true },
{ name: "tree_species_id", type: "number" },
{ name: "trees_planted_count", type: "number" },
{ name: "remarks", type: "string", isOptional: true },
{ name: "latitude", type: "number", isOptional: true },
{ name: "longitude", type: "number", isOptional: true },
{ name: "is_synced", type: "boolean" },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
tableSchema({
name: "confirmation_images",
columns: [
{ name: "confirmation_id", type: "string", isIndexed: true },
{ name: "local_uri", type: "string" },
{ name: "remote_url", type: "string", isOptional: true },
{ name: "is_uploaded", type: "boolean" },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
],
});
Key decisions worth noting:
-
server_idvs WatermelonDB's internalid: WatermelonDB generates its own UUIDs for all records. Your server's integer IDs go inserver_id. This dual-ID pattern is essential for sync โ you need to map between local and remote records. -
is_syncedflag: WatermelonDB's sync protocol uses its own tracking, but an explicit flag gives you query-level visibility (showing a "pending sync" badge in the UI, for example). - Timestamps as numbers: WatermelonDB stores dates as Unix timestamps in milliseconds. Do not fight this โ convert at the boundary.
-
isIndexedon foreign keys and filter columns: SQLite indexes make a real difference on 10k+ record tables. Index anything you filter or join on.
๐๏ธ Model Classes
Models are where WatermelonDB gets opinionated. With new architecture and TypeScript 5.9, we avoid legacy decorators and use the field descriptor API:
// src/database/models/Task.ts
import { Model } from "@nozbe/watermelondb";
import {
field,
text,
date,
readonly,
children,
writer,
} from "@nozbe/watermelondb/decorators";
import type { Associations } from "@nozbe/watermelondb/Model";
export default class Task extends Model {
static table = "tasks";
static associations: Associations = {
task_confirmations: { type: "has_many" as const, foreignKey: "task_id" },
};
@field("server_id") serverId!: number;
@text("title") title!: string;
@text("description") description!: string;
@text("status") status!: string;
@text("task_type") taskType!: string;
@date("due_date") dueDate!: Date;
@text("location_name") locationName!: string;
@field("latitude") latitude!: number | null;
@field("longitude") longitude!: number | null;
@field("is_synced") isSynced!: boolean;
@field("last_modified") lastModified!: number;
@readonly @date("created_at") createdAt!: Date;
@readonly @date("updated_at") updatedAt!: Date;
@children("task_confirmations") confirmations!: any;
@writer async markAsSynced(): Promise<void> {
await this.update((record) => {
record.isSynced = true;
});
}
@writer async updateStatus(newStatus: string): Promise<void> {
await this.update((record) => {
record.status = newStatus;
record.isSynced = false; // Mark for re-sync
});
}
}
// src/database/models/TaskConfirmation.ts
import { Model } from "@nozbe/watermelondb";
import {
field,
text,
date,
readonly,
relation,
children,
writer,
} from "@nozbe/watermelondb/decorators";
import type { Associations } from "@nozbe/watermelondb/Model";
export default class TaskConfirmation extends Model {
static table = "task_confirmations";
static associations: Associations = {
tasks: { type: "belongs_to" as const, key: "task_id" },
confirmation_images: {
type: "has_many" as const,
foreignKey: "confirmation_id",
},
};
@relation("tasks", "task_id") task!: any;
@field("tree_species_id") treeSpeciesId!: number;
@field("trees_planted_count") treesPlantedCount!: number;
@text("remarks") remarks!: string | null;
@field("latitude") latitude!: number | null;
@field("longitude") longitude!: number | null;
@field("is_synced") isSynced!: boolean;
@readonly @date("created_at") createdAt!: Date;
@readonly @date("updated_at") updatedAt!: Date;
@children("confirmation_images") images!: any;
@writer async markAsSynced(): Promise<void> {
await this.update((record) => {
record.isSynced = true;
});
}
}
// src/database/models/ConfirmationImage.ts
import { Model } from "@nozbe/watermelondb";
import {
field,
text,
date,
readonly,
relation,
writer,
} from "@nozbe/watermelondb/decorators";
import type { Associations } from "@nozbe/watermelondb/Model";
export default class ConfirmationImage extends Model {
static table = "confirmation_images";
static associations: Associations = {
task_confirmations: { type: "belongs_to" as const, key: "confirmation_id" },
};
@relation("task_confirmations", "confirmation_id") confirmation!: any;
@text("local_uri") localUri!: string;
@text("remote_url") remoteUrl!: string | null;
@field("is_uploaded") isUploaded!: boolean;
@readonly @date("created_at") createdAt!: Date;
@readonly @date("updated_at") updatedAt!: Date;
@writer async markAsUploaded(url: string): Promise<void> {
await this.update((record) => {
record.remoteUrl = url;
record.isUploaded = true;
});
}
}
๐ Why decorators still work: WatermelonDB uses TypeScript experimental decorators (stage 2), not the TC39 standard decorators (stage 3). TypeScript 5.9 supports both โ you need
"experimentalDecorators": truein yourtsconfig.json. This is a known rough edge the WatermelonDB team has not resolved yet.
๐ Database Initialization
// src/database/index.ts
import "./polyfills"; // MUST be first import โ see below
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { schema } from "./schema";
import migrations from "./migrations";
import Task from "./models/Task";
import TaskConfirmation from "./models/TaskConfirmation";
import ConfirmationImage from "./models/ConfirmationImage";
const adapter = new SQLiteAdapter({
schema,
migrations,
jsi: true,
onSetUpError: (error) => {
// Database setup failed โ this is fatal
// In production you might want to:
// 1. Report to your crash analytics
// 2. Attempt to delete and recreate the database
// 3. Show a "please reinstall" screen as last resort
console.error("WatermelonDB setup failed:", error);
},
});
export const database = new Database({
adapter,
modelClasses: [Task, TaskConfirmation, ConfirmationImage],
});
๐ The window.performance.now.bind Crash
If you hit this error on app startup:
TypeError: window.performance.now.bind is not a function
This is a known issue where WatermelonDB's internal performance tracking assumes a browser-like window.performance API. The fix is a polyfill that must run before the database is imported:
// src/database/polyfills.ts
// Must be imported before any WatermelonDB import
if (typeof window !== "undefined" && window.performance) {
const originalNow = window.performance.now;
if (
typeof originalNow === "function" &&
typeof originalNow.bind !== "function"
) {
const now = (): number => originalNow.call(window.performance);
window.performance.now = now as typeof window.performance.now;
}
}
export {}; // ensure this is treated as a module
The import './polyfills' at the top of src/database/index.ts handles the ordering automatically.
๐ Schema Migrations
When you add columns or tables in a future version:
// src/database/migrations.ts
import {
schemaMigrations,
addColumns,
} from "@nozbe/watermelondb/Schema/migrations";
export default schemaMigrations({
migrations: [
// Example: adding a priority column in version 2
// {
// toVersion: 2,
// steps: [
// addColumns({
// table: 'tasks',
// columns: [
// { name: 'priority', type: 'string', isOptional: true },
// ],
// }),
// ],
// },
],
});
๐ Production tip: Always keep your migrations file even if it is empty. When you need to add a column six months from now and your app is on 50k devices, you will be glad the infrastructure is already in place. Always test migrations on a device running the old schema before shipping โ simulators with fresh installs do not catch migration bugs.
โ๏ธ EAS Build Configuration
// eas.json
{
"cli": {
"version": ">= 15.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"buildConfiguration": "Debug",
"simulator": true
},
"android": {
"buildType": "apk"
},
"env": {
"EXPO_NO_TELEMETRY": "1"
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
},
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"ios": {
"buildConfiguration": "Release"
},
"android": {
"buildType": "app-bundle"
}
}
}
}
โ ๏ธ Important: WatermelonDB is a native module. You cannot use Expo Go. You must use a development build (
npx expo run:iosoreas build --profile development). OTA updates via EAS Update work for JavaScript changes, but any WatermelonDB version bump or schema change requires a new native build.
๐ iOS-Specific: The simdjson Duplicate Dependency
This one will catch you. After npx expo prebuild, running pod install fails with:
[!] There are multiple dependencies with different sources for `simdjson` in `Podfile`
What is happening: The WatermelonDB Expo config plugin inserts a manual pod 'simdjson' line into your Podfile. But Expo's autolinking also discovers @nozbe/simdjson in node_modules and tries to link it. Two sources, one pod, CocoaPods rejects it.
Fix: Open ios/Podfile and delete the manual simdjson line the plugin added:
# DELETE this entire line โ autolinking already handles it
pod 'simdjson', path: File.join(File.dirname(`node --print "require.resolve('@nozbe/simdjson/package.json')"`)), :modular_headers => true
Then re-run:
cd ios && pod install && cd ..
Apple Silicon Simulator Build Failure ๐ฅ๏ธ
If you hit a build failure on M1/M2/M3 related to simdjson architecture, add this to your Podfile's post_install block:
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'i386'
end
end
end
๐ท TypeScript Configuration
Ensure your tsconfig.json includes:
{
"compilerOptions": {
"strict": true,
"experimentalDecorators": true,
"jsx": "react-jsx"
}
}
experimentalDecorators is required for WatermelonDB's model decorators. Non-negotiable until WatermelonDB migrates to standard decorators.
โ Takeaway: If you followed these steps exactly with the versions listed, you have a working WatermelonDB setup on Expo SDK 54. If something broke, the Troubleshooting section at the end catalogs every error you are likely to hit.
๐ Checkpoint โ seriously, well done. You just got through the hardest part of this entire guide. The setup section alone is what hundreds of developers have been hunting for across GitHub issues, Discord threads, and unanswered Stack Overflow posts. You now have a working native database on Expo SDK 54 with new arch enabled. What is left is the fun part โ making it actually sync.
๐ Sync Implementation โ The Full-Stack View
This is where WatermelonDB's "you own the sync" philosophy becomes real work. The protocol is well-specified but you build every piece.
๐ง The Sync Protocol (Mental Model)
WatermelonDB's sync is a two-phase operation:
Client Server
โ โ
โโโโ PULL โโโโโโโโโโโโโโโโโโโโโโบโ "Give me everything changed since timestamp X"
โโโโ {created, updated, deleted}โ Server responds with change sets per table
โ โ
โโโโ PUSH โโโโโโโโโโโโโโโโโโโโโโบโ "Here's everything I changed locally"
โ {created, updated, deleted}โ Server validates and applies
โโโโ OK or REJECT โโโโโโโโโโโโโโ If conflict detected โ reject entire push
โ โ
โโโโ (if rejected) PULL againโโโบโ Re-pull, merge, retry push
โ โ
Critical detail: The pull must return a consistent snapshot. On the server, wrap your pull query in a transaction with a serializable isolation level. If records change mid-pull, the client will have an inconsistent view and the next push will likely fail.
๐ฅ๏ธ Expo API Routes โ The Demo Backend
For the demo project, I used Expo Router API routes โ server-side endpoints that live inside the same repo as the mobile app. No separate backend project, no extra process to run. npx expo start serves both the mobile app and the sync API.
The sync server state lives in an in-memory store (swap out for Postgres in production):
// src/services/serverStore.ts
import type { RawTaskRecord, RawConfirmationRecord } from "../types";
export let serverTasks: RawTaskRecord[] = generateSeedTasks();
export let serverConfirmations: RawConfirmationRecord[] = [];
export function applyTaskUpdate(
updated: Partial<RawTaskRecord> & { id: string },
): void {
const idx = serverTasks.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
serverTasks[idx] = {
...serverTasks[idx],
...updated,
last_modified: Date.now(),
};
}
}
export function addConfirmation(record: RawConfirmationRecord): void {
serverConfirmations.push({ ...record, is_synced: true });
}
Pull endpoint โ returns all changes since the client's last sync:
// app/api/sync/pull+api.ts
import {
serverTasks,
serverConfirmations,
} from "../../../src/services/serverStore";
export function GET(request: Request): Response {
const url = new URL(request.url);
const lastPulledAt = Number(url.searchParams.get("last_pulled_at") ?? "0");
const isFirstPull = lastPulledAt === 0;
if (isFirstPull) {
return Response.json({
changes: {
tasks: { created: serverTasks, updated: [], deleted: [] },
task_confirmations: {
created: serverConfirmations,
updated: [],
deleted: [],
},
},
timestamp: Date.now(),
});
}
const modifiedTasks = serverTasks.filter(
(t) => t.last_modified > lastPulledAt,
);
const newTasks = modifiedTasks.filter((t) => t.created_at > lastPulledAt);
const updatedTasks = modifiedTasks.filter(
(t) => t.created_at <= lastPulledAt,
);
return Response.json({
changes: {
tasks: { created: newTasks, updated: updatedTasks, deleted: [] },
task_confirmations: { created: [], updated: [], deleted: [] },
},
timestamp: Date.now(),
});
}
Push endpoint โ receives client changes and applies them server-side:
// app/api/sync/push+api.ts
import {
applyTaskUpdate,
addConfirmation,
} from "../../../src/services/serverStore";
export async function POST(request: Request): Promise<Response> {
const { changes } = await request.json();
if (changes.tasks) {
for (const updated of changes.tasks.updated) {
applyTaskUpdate(updated);
}
}
if (changes.task_confirmations) {
for (const created of changes.task_confirmations.created) {
addConfirmation(created);
}
}
return Response.json({ ok: true });
}
๐ Why this works for a demo but not production: The in-memory store resets when the dev server restarts. There is no auth, no conflict detection, no transactions. But the shape of the API is identical to what WatermelonDB expects โ so swapping in a real database is replacing the store functions with SQL queries.
๐ช Client Sync Hook
// src/hooks/useSync.ts
import { synchronize } from "@nozbe/watermelondb/sync";
import { database } from "../database";
import { API_BASE_URL } from "../constants/config";
const performSync = async (): Promise<void> => {
await synchronize({
database,
pullChanges: async ({ lastPulledAt }) => {
const response = await fetch(
`${API_BASE_URL}/api/sync/pull?last_pulled_at=${lastPulledAt ?? 0}`,
);
if (!response.ok) {
throw new Error(`Pull failed: ${response.status}`);
}
return response.json();
},
pushChanges: async ({ changes, lastPulledAt }) => {
const response = await fetch(`${API_BASE_URL}/api/sync/push`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes, last_pulled_at: lastPulledAt }),
});
if (!response.ok) {
throw new Error(`Push failed: ${response.status}`);
}
},
migrationsEnabledAtVersion: 1,
});
};
Key design decisions:
- An
isSyncingref guard (in the full hook) prevents concurrent sync calls. WatermelonDB'ssynchronizeis not re-entrant โ two concurrent calls will corrupt state. - Pull sends
lastPulledAtโ WatermelonDB manages this timestamp automatically. The first sync sends0, which tells the server "give me everything." - Push errors do not clear local changes. If push fails, your local mutations stay in WatermelonDB's internal change tracking and will be included in the next push attempt automatically.
๐ Auto-Sync on Reconnect
// src/providers/SyncProvider.tsx
import React, { createContext, useEffect, type PropsWithChildren } from "react";
import { AppState } from "react-native";
import { useSync } from "../hooks/useSync";
import { useNetInfo } from "../hooks/useNetInfo";
export const SyncProvider = ({ children }: PropsWithChildren) => {
const { sync, isSyncing, lastSyncedAt, lastError } = useSync();
const { isOnline } = useNetInfo();
// Sync when connectivity is restored
useEffect(() => {
if (isOnline) sync();
}, [isOnline]);
// Sync when app returns to foreground
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state === "active") sync();
});
return () => sub.remove();
}, [sync]);
// Initial sync on mount
useEffect(() => {
sync();
}, []);
return (
<SyncContext.Provider
value={{ isSyncing, lastSyncedAt, lastError, triggerSync: sync }}
>
{children}
</SyncContext.Provider>
);
};
๐ญ Production Backend: NestJS + PostgreSQL
When you move beyond the demo, replace the in-memory store with a real database. The sync contract stays identical โ the client does not change, only the server implementation.
// sync.controller.ts (NestJS)
import {
Controller,
Get,
Post,
Query,
Body,
UseGuards,
Req,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard";
import { SyncService } from "./sync.service";
@Controller("sync")
@UseGuards(AuthGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}
@Get("pull")
async pull(
@Req() req: { user: { id: number } },
@Query("last_pulled_at") lastPulledAt: string,
) {
return this.syncService.pull(req.user.id, parseInt(lastPulledAt, 10) || 0);
}
@Post("push")
async push(
@Req() req: { user: { id: number } },
@Body() body: { changes: Record<string, any>; last_pulled_at: number },
) {
return this.syncService.push(
req.user.id,
body.changes,
body.last_pulled_at,
);
}
}
// sync.service.ts (NestJS)
import { Injectable, ConflictException } from "@nestjs/common";
import { DataSource } from "typeorm";
@Injectable()
export class SyncService {
constructor(private readonly dataSource: DataSource) {}
async pull(userId: number, lastPulledAt: number) {
// SERIALIZABLE transaction โ prevents phantom reads during pull
return this.dataSource.transaction("SERIALIZABLE", async (manager) => {
const since = new Date(lastPulledAt);
const allTasks = await manager.query(
`SELECT * FROM tasks WHERE user_id = $1 AND last_modified_at > $2`,
[userId, since],
);
const created = allTasks.filter(
(t: any) => t.created_at > since && !t.deleted_at,
);
const updated = allTasks.filter(
(t: any) =>
t.created_at <= since && !t.deleted_at && t.last_modified_at > since,
);
const deleted = allTasks
.filter((t: any) => t.deleted_at && t.deleted_at > since)
.map((t: any) => t.watermelon_id);
return {
changes: {
tasks: {
created: created.map(this.toWatermelonRecord),
updated: updated.map(this.toWatermelonRecord),
deleted,
},
},
timestamp: Date.now(),
};
});
}
async push(
userId: number,
changes: Record<string, any>,
lastPulledAt: number,
) {
return this.dataSource.transaction("SERIALIZABLE", async (manager) => {
for (const [tableName, tableChanges] of Object.entries(changes)) {
for (const record of (tableChanges as any).updated || []) {
const serverRecord = await manager.query(
`SELECT last_modified_at FROM ${tableName} WHERE watermelon_id = $1`,
[record.id],
);
if (
serverRecord.length > 0 &&
new Date(serverRecord[0].last_modified_at).getTime() > lastPulledAt
) {
throw new ConflictException(
`Conflict: ${tableName}/${record.id} modified after client's last pull`,
);
}
await manager.query(
`UPDATE ${tableName} SET title = $2, status = $3, updated_at = NOW() WHERE watermelon_id = $1`,
[record.id, record.title, record.status],
);
}
for (const record of (tableChanges as any).created || []) {
await manager.query(
`INSERT INTO ${tableName} (watermelon_id, title, status, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (watermelon_id) DO NOTHING`,
[record.id, record.title, record.status],
);
}
for (const id of (tableChanges as any).deleted || []) {
await manager.query(
`UPDATE ${tableName} SET deleted_at = NOW() WHERE watermelon_id = $1`,
[id],
);
}
}
});
}
private toWatermelonRecord(dbRecord: any) {
return {
id: dbRecord.watermelon_id,
server_id: dbRecord.id,
title: dbRecord.title,
description: dbRecord.description,
status: dbRecord.status,
task_type: dbRecord.task_type,
due_date: new Date(dbRecord.due_date).getTime(),
location_name: dbRecord.location_name,
latitude: dbRecord.latitude,
longitude: dbRecord.longitude,
is_synced: true,
last_modified: new Date(dbRecord.last_modified_at).getTime(),
created_at: new Date(dbRecord.created_at).getTime(),
updated_at: new Date(dbRecord.updated_at).getTime(),
};
}
}
Key backend requirements for production:
- ๐
SERIALIZABLEtransaction isolation โ prevents phantom reads during pull. Without this, a record could be modified between yourSELECTand the response. - ๐
watermelon_idcolumn โ every synced table stores the WatermelonDB-generated UUID alongside your server's auto-incrementid. This is the join key between client and server. - ๐๏ธ Soft deletes โ WatermelonDB's sync protocol requires deleted records to be returned in the pull response. Hard deletes break sync. Use a
deleted_atcolumn. - โ ๏ธ Conflict means reject entire push โ the client re-pulls the latest state, WatermelonDB merges internally, and retries. Simpler than field-level merge, but conflicts cause an extra round trip.
๐ The migration path from demo to production: Your mobile code does not change at all. Point
EXPO_PUBLIC_API_URLat your Node.js server instead oflocalhost:8081. The sync contract is identical โ only the backing store changes from in-memory arrays to Postgres tables.
โก Conflict Resolution in Practice
WatermelonDB's "reject on conflict" strategy is simple but has UX implications.
// โ Don't silently retry forever
const syncWithRetry = async (): Promise<void> => {
while (true) {
try {
await sync();
return;
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
};
// โ
Bounded retry with backoff and user feedback
const MAX_SYNC_RETRIES = 3;
const syncWithRetry = async (): Promise<{
success: boolean;
retries: number;
}> => {
let retries = 0;
while (retries < MAX_SYNC_RETRIES) {
try {
await sync();
return { success: true, retries };
} catch (error) {
retries++;
if (retries >= MAX_SYNC_RETRIES) {
return { success: false, retries };
}
// Exponential backoff: 1s, 2s, 4s
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, retries - 1)));
}
}
return { success: false, retries };
};
๐ก Takeaway: The sync is the hard part. WatermelonDB gives you a protocol, not a solution. You build the server, handle conflicts, manage retries, and decide what the user sees when things do not line up.
๐ฅ You are in the home stretch. Everything from here is hardening, troubleshooting, and decision-making guidance. The core architecture is done. Take a breath โ you have just built something most React Native tutorials never even attempt to explain properly.
๐ก๏ธ Production Hardening
Schema Migrations Between App Versions
When you add a column after launch:
- Increment
versioninschema.ts - Add a migration step in
migrations.ts - ๐งช Test on a device running the old version first โ do not just test on a fresh install
export default schemaMigrations({
migrations: [
{
toVersion: 2,
steps: [
addColumns({
table: "tasks",
columns: [{ name: "priority", type: "string", isOptional: true }],
}),
],
},
],
});
โข๏ธ Database Reset Strategy
Sometimes migrations fail (corrupted local DB, dramatic schema changes between versions). Have a fallback:
const adapter = new SQLiteAdapter({
schema,
migrations,
jsi: true,
onSetUpError: (error) => {
// Nuclear option: delete and recreate
// Only do this if you can re-sync all data from the server
database.unsafeResetDatabase();
},
});
โ ๏ธ Warning:
unsafeResetDatabase()deletes all local data. Only use this if your server is the source of truth and the client can re-pull everything. For apps where users create data offline that has not synced yet, this means data loss. Show a confirmation dialog first.
โก Performance Tips
-
Batch writes: Use
database.write(async (writer) => { ... })to group multiple record operations into a single transaction. Creating 100 records one at a time = 100 SQLite transactions. Batching = 1 transaction. -
Index your filter columns: If you query
tasksbystatusfrequently, make surestatushasisIndexed: truein the schema. -
Use
@lazyfor expensive relations:@lazydefers loading until the property is accessed, preventing N+1 query patterns. -
Limit observed queries: Every
observe()subscription is a live SQLite query. Do not observe tables you are not rendering.
๐ Troubleshooting โ The Errors You Will Actually Hit
๐ฌ Each of these cost me real hours. Consider this section a gift.
โ window.performance.now.bind is not a function
When: App startup, before any database operations.
Cause: WatermelonDB's internal performance measurement assumes browser-like performance.now with a .bind method. Hermes does not provide this.
Fix: The polyfill in the Database Initialization section above. Must be imported before any WatermelonDB import.
โ simdjson duplicate dependency in Podfile
When: Running pod install after npx expo prebuild.
Error:
[!] There are multiple dependencies with different sources for `simdjson` in `Podfile`
Cause: The WatermelonDB Expo config plugin adds a manual pod 'simdjson' line to your Podfile. Expo's autolinking also discovers @nozbe/simdjson in node_modules. Two sources for the same pod = conflict.
Fix: Open ios/Podfile, delete the manual simdjson line the plugin added, then re-run cd ios && pod install.
โ simdjson build failure on Apple Silicon simulators
When: Building for iOS simulator on an M1/M2/M3 Mac.
Cause: simdjson does not build for the i386 architecture, which the simulator SDK still references.
Fix: Add 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' = 'i386' to your Podfile's post_install block.
โ @nozbe/with-observables React 19 peer dependency warning
When: npm install or npx expo install.
Cause: Package declares react@^16||^17||^18, does not include 19.
Fix: overrides in package.json โ Option A from the Installation section.
โ JSI not loading on Android
When: Database operations crash or fall back to bridge mode silently.
Cause: The Expo config plugin did not link the JSI native module correctly for the new architecture build.
Fix: Verify that android/app/build.gradle includes the watermelondb-jsi project dependency.
โ EAS Update OTA push does not include schema changes
When: You shipped a schema version bump via EAS Update (OTA) instead of a native build.
Cause: WatermelonDB schema changes require SQLite DDL operations that only run with a native binary rebuild. OTA updates only ship JavaScript.
Fix: Any schema or migration change requires eas build. Use OTA updates only for JavaScript/UI changes that do not touch the database schema.
๐ Migration Path: From MMKV Queues to WatermelonDB
If you are currently running the MMKV queue pattern and want to migrate, here is the sequence:
Phase 1 โ Read cache migration. Replace TanStack Query's MMKV persister with WatermelonDB for your most-queried data. Keep the MMKV queue for writes. This lets you validate the WatermelonDB setup without touching your write path.
Phase 2 โ Write queue migration. Move offline mutations from the MMKV queue to WatermelonDB records with an is_synced: false flag. Your sync hook now queries WatermelonDB for unsynced records instead of reading the MMKV queue.
Phase 3 โ Full sync. Implement the pull/push sync protocol. Remove the MMKV queue entirely. TanStack Query can still be used for non-synced data (user profile, app config).
๐ฆ Run both systems in parallel during transition. Do not rip out MMKV until WatermelonDB sync has been stable in production for at least two release cycles. Feature-flag it if your team supports that.
๐ฅ Pick Your Fighter: The Decision Tree
Not every app needs the same offline strategy. Here is how to decide:
"I just need to cache API responses"
โ ๐ข TanStack Query + MMKV persister. Done. Do not overcomplicate it.
"I need to queue mutations while offline"
โ ๐ข TanStack Query + MMKV mutation queue. The pattern from the first section. Works for simple, linear write operations.
"I need to query offline data โ filter, sort, search across thousands of records"
โ ๐ก You need a local database. Choose between:
- WatermelonDB โ full control, MIT license, you build the sync server
- PowerSync โ managed sync, $49/mo+, minimal backend work
"I need real-time sync with Postgres and I want open source"
โ ๐ก ElectricSQL + TanStack DB. Generous free tier, Apache 2.0, but the React Native persistence layer is newer.
"I need real-time collaboration with automatic conflict resolution"
โ ๐ต Jazz.tools (CRDT-based, first-class Expo support) or evaluate CRDTs (Automerge/Yjs) if you need fine-grained control.
๐ Conclusion
I have shipped all three levels of offline architecture in production: the MMKV cache, the mutation queue, and the full reactive database with sync. Each was the right call at the time. The MMKV pattern is where I started โ it is honest, pragmatic, and handles more cases than people give it credit for. But when I needed relational queries on thousands of offline records, conflict-aware sync, and reactive UI that updates when the local database changes, I needed a real offline-first architecture.
WatermelonDB delivered that. Setting it up on Expo SDK 54 with mandatory new architecture was genuinely painful โ community plugins, peer dependency overrides, native patches, polyfills. But once it is running, the developer experience is excellent: observable queries, lazy-loaded relations, and a sync protocol that forces you to think about conflicts instead of ignoring them.
The landscape is moving fast. PowerSync is solving the "I do not want to build a sync server" problem well. ElectricSQL + TanStack DB is the most exciting open-source path. WatermelonDB's maintenance pace is a real concern โ if you are starting fresh today, evaluate all three.
But if you need full control, zero vendor dependency, and you have the backend team to build the sync โ WatermelonDB still earns its place. You just needed this guide to get past the setup.
๐งช Demo Project
Clone the repo, run it, break it. The entire working implementation from this article is there โ Expo API routes, WatermelonDB setup, sync hooks, models, schema, all of it.
github.com/FastheDeveloper/watermelondb-expo-offline-demo
git clone https://github.com/FastheDeveloper/watermelondb-expo-offline-demo.git
cd watermelondb-expo-offline-demo
npm install
npx expo run:ios # or npx expo run:android
If you find a setup issue not covered here, open an issue and I will update this guide. ๐
Find me on Twitter/X and LinkedIn. I write about React Native architecture, offline-first patterns, and the things tutorials skip over.
Top comments (0)