React Native Offline-First with WatermelonDB: Sync Queue and Conflict Resolution
Build offline-first React Native apps with WatermelonDB. Covers local database setup, sync queue for API calls, conflict resolution strategies, network state detection, and background sync.
Mobile apps live in an unreliable network world. Users open your app on the subway, at a job site without signal, or in a country with spotty coverage. If every interaction requires an API call, the app is broken for those users. Offline-first design flips this: the local database is the source of truth, changes happen instantly, and sync happens when connectivity returns.
WatermelonDB is the production choice for offline-first React Native apps. It's built on SQLite with a reactive Observables interface, lazy loading for performance, and a synchronization protocol designed for multi-device conflict handling.
Architecture
User Action
โ Write to WatermelonDB (local SQLite)
โ UI updates immediately (reactive)
โ Change queued in sync_queue table
โ NetInfo detects connectivity
โ SyncService pushes changes to API
โ API returns server changes
โ WatermelonDB merges with conflict resolution
Installation
# Core
npm install @nozbe/watermelondb
npm install @nozbe/with-observables
# Native dependencies
npm install @react-native-community/netinfo
npm install react-native-mmkv # Fast key-value storage for metadata
npx pod-install
Add the Babel plugin:
// babel.config.js
{
"presets": ["babel-preset-expo"],
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }]
]
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Database Schema and Models
// database/schema.ts
import { appSchema, tableSchema } from "@nozbe/watermelondb";
export const schema = appSchema({
version: 3,
tables: [
tableSchema({
name: "tasks",
columns: [
{ name: "title", type: "string" },
{ name: "description", type: "string", isOptional: true },
{ name: "status", type: "string" }, // 'todo' | 'in_progress' | 'done'
{ name: "priority", type: "number" },
{ name: "due_date", type: "number", isOptional: true },
{ name: "project_id", type: "string", isIndexed: true },
{ name: "assignee_id", type: "string", isOptional: true },
{ name: "is_deleted", type: "boolean" },
{ name: "server_id", type: "string", isOptional: true, isIndexed: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
tableSchema({
name: "projects",
columns: [
{ name: "name", type: "string" },
{ name: "color", type: "string" },
{ name: "workspace_id", type: "string", isIndexed: true },
{ name: "is_deleted", type: "boolean" },
{ name: "server_id", type: "string", isOptional: true, isIndexed: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
tableSchema({
name: "sync_queue",
columns: [
{ name: "table_name", type: "string" },
{ name: "record_id", type: "string" }, // local WatermelonDB ID
{ name: "server_id", type: "string", isOptional: true },
{ name: "operation", type: "string" }, // 'create' | 'update' | 'delete'
{ name: "payload", type: "string" }, // JSON
{ name: "attempt_count", type: "number" },
{ name: "last_error", type: "string", isOptional: true },
{ name: "status", type: "string" }, // 'pending' | 'syncing' | 'failed'
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
],
}),
],
});
// database/models/Task.ts
import { Model, field, date, readonly, relation } from "@nozbe/watermelondb";
import { action } from "@nozbe/watermelondb/decorators";
export class Task extends Model {
static table = "tasks";
static associations = {
projects: { type: "belongs_to" as const, key: "project_id" },
};
@field("title") title!: string;
@field("description") description!: string | null;
@field("status") status!: "todo" | "in_progress" | "done";
@field("priority") priority!: number;
@field("due_date") dueDate!: number | null;
@field("project_id") projectId!: string;
@field("assignee_id") assigneeId!: string | null;
@field("is_deleted") isDeleted!: boolean;
@field("server_id") serverId!: string | null;
@readonly @date("created_at") createdAt!: Date;
@date("updated_at") updatedAt!: Date;
@relation("projects", "project_id") project!: Project;
@action async markDone() {
await this.update((task) => {
task.status = "done";
});
}
@action async softDelete() {
await this.update((task) => {
task.isDeleted = true;
});
}
}
Database Initialization
// database/index.ts
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { schema } from "./schema";
import { Task } from "./models/Task";
import { Project } from "./models/Project";
import { SyncQueueEntry } from "./models/SyncQueueEntry";
import { migrations } from "./migrations";
const adapter = new SQLiteAdapter({
schema,
migrations,
jsi: true, // JSI for up to 10ร faster SQLite access (Expo + bare RN)
onSetUpError: (error) => {
console.error("WatermelonDB setup error:", error);
},
});
export const database = new Database({
adapter,
modelClasses: [Task, Project, SyncQueueEntry],
});
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Sync Service
WatermelonDB has a built-in synchronization protocol that works with a compatible server API:
// services/sync.ts
import { synchronize } from "@nozbe/watermelondb/sync";
import { database } from "@/database";
import { MMKV } from "react-native-mmkv";
import NetInfo from "@react-native-community/netinfo";
const storage = new MMKV({ id: "sync-metadata" });
const API_BASE = process.env.EXPO_PUBLIC_API_URL!;
export async function syncDatabase(): Promise<{ success: boolean; error?: string }> {
// Check connectivity
const netState = await NetInfo.fetch();
if (!netState.isConnected) {
return { success: false, error: "No network connection" };
}
const authToken = storage.getString("auth_token");
if (!authToken) {
return { success: false, error: "Not authenticated" };
}
try {
await synchronize({
database,
// Pull: get changes from server since last sync
pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
const params = new URLSearchParams({
last_pulled_at: String(lastPulledAt ?? 0),
schema_version: String(schemaVersion),
migration: JSON.stringify(migration),
});
const response = await fetch(`${API_BASE}/api/sync/pull?${params}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!response.ok) {
throw new Error(`Sync pull failed: ${response.status}`);
}
const { changes, timestamp } = await response.json();
return { changes, timestamp };
},
// Push: send local changes to server
pushChanges: async ({ changes, lastPulledAt }) => {
const response = await fetch(`${API_BASE}/api/sync/push`, {
method: "POST",
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ changes, lastPulledAt }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message ?? "Sync push failed");
}
},
// Conflict resolution: server wins (simplest correct approach)
conflictResolver: (tableName, local, remote) => {
// Server always wins for most fields
// But preserve local-only fields (like UI state)
return {
...remote,
// Keep local pending changes for fields not on server
_status: local._status,
_changed: local._changed,
};
},
migrationsEnabledAtVersion: 1,
});
storage.set("last_sync_at", Date.now());
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Sync failed";
console.error("Sync error:", error);
return { success: false, error: message };
}
}
Server-Side Sync API
// app/api/sync/pull/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const lastPulledAt = parseInt(searchParams.get("last_pulled_at") ?? "0");
const workspaceId = session.user.organizationId;
const timestamp = Date.now();
const since = new Date(lastPulledAt);
// Fetch all changes since last pull
const [createdTasks, updatedTasks, deletedTaskIds] = await Promise.all([
// New records since last pull
prisma.task.findMany({
where: {
workspaceId,
createdAt: { gt: since },
deletedAt: null,
},
select: {
id: true,
title: true,
description: true,
status: true,
priority: true,
dueDate: true,
projectId: true,
assigneeId: true,
createdAt: true,
updatedAt: true,
},
}),
// Updated records since last pull
prisma.task.findMany({
where: {
workspaceId,
updatedAt: { gt: since },
createdAt: { lte: since },
deletedAt: null,
},
select: {
id: true,
title: true,
description: true,
status: true,
priority: true,
dueDate: true,
projectId: true,
assigneeId: true,
createdAt: true,
updatedAt: true,
},
}),
// Soft-deleted records since last pull
prisma.task
.findMany({
where: {
workspaceId,
deletedAt: { gt: since },
},
select: { id: true },
})
.then((tasks) => tasks.map((t) => t.id)),
]);
const changes = {
tasks: {
created: createdTasks.map(toWatermelonRecord),
updated: updatedTasks.map(toWatermelonRecord),
deleted: deletedTaskIds,
},
// Repeat for other tables...
};
return NextResponse.json({ changes, timestamp });
}
function toWatermelonRecord(record: any) {
return {
id: record.id,
title: record.title,
description: record.description ?? null,
status: record.status,
priority: record.priority,
due_date: record.dueDate ? record.dueDate.getTime() : null,
project_id: record.projectId,
assignee_id: record.assigneeId ?? null,
is_deleted: false,
server_id: record.id,
created_at: record.createdAt.getTime(),
updated_at: record.updatedAt.getTime(),
};
}
// app/api/sync/push/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { changes } = await req.json();
const workspaceId = session.user.organizationId;
// Process task changes
const taskChanges = changes.tasks;
await prisma.$transaction(async (tx) => {
// Created tasks
for (const task of taskChanges.created ?? []) {
await tx.task.create({
data: {
id: task.server_id ?? undefined, // Use server ID if provided
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
dueDate: task.due_date ? new Date(task.due_date) : null,
projectId: task.project_id,
assigneeId: task.assignee_id,
workspaceId,
createdBy: session.user.id,
},
});
}
// Updated tasks
for (const task of taskChanges.updated ?? []) {
await tx.task.updateMany({
where: {
id: task.server_id ?? task.id,
workspaceId,
},
data: {
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
dueDate: task.due_date ? new Date(task.due_date) : null,
updatedAt: new Date(),
},
});
}
// Deleted tasks (soft delete)
for (const taskId of taskChanges.deleted ?? []) {
await tx.task.updateMany({
where: { id: taskId, workspaceId },
data: { deletedAt: new Date() },
});
}
});
return NextResponse.json({ success: true });
}
Network State Detection and Auto-Sync
// services/sync-manager.ts
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
import { AppState, AppStateStatus } from "react-native";
import { syncDatabase } from "./sync";
import { EventEmitter } from "eventemitter3";
class SyncManager extends EventEmitter {
private isSyncing = false;
private syncTimer: ReturnType<typeof setInterval> | null = null;
private unsubscribeNetInfo: (() => void) | null = null;
private unsubscribeAppState: (() => void) | null = null;
private lastSyncAt = 0;
private MIN_SYNC_INTERVAL = 30_000; // 30 seconds between syncs
start() {
// Listen for network changes
this.unsubscribeNetInfo = NetInfo.addEventListener(
this.handleNetworkChange
);
// Listen for app foregrounding
this.unsubscribeAppState = AppState.addEventListener(
"change",
this.handleAppStateChange
).remove;
// Periodic background sync every 5 minutes
this.syncTimer = setInterval(() => this.triggerSync("periodic"), 5 * 60_000);
// Initial sync on start
this.triggerSync("startup");
}
stop() {
this.unsubscribeNetInfo?.();
this.unsubscribeAppState?.();
if (this.syncTimer) {
clearInterval(this.syncTimer);
this.syncTimer = null;
}
}
private handleNetworkChange = (state: NetInfoState) => {
if (state.isConnected && state.isInternetReachable) {
this.triggerSync("network_restored");
}
};
private handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === "active") {
this.triggerSync("app_foregrounded");
}
};
async triggerSync(reason: string) {
const now = Date.now();
if (this.isSyncing || now - this.lastSyncAt < this.MIN_SYNC_INTERVAL) {
return;
}
console.log(`[SyncManager] Triggering sync: ${reason}`);
this.isSyncing = true;
this.emit("sync:start", { reason });
const result = await syncDatabase();
this.lastSyncAt = Date.now();
this.isSyncing = false;
if (result.success) {
this.emit("sync:success", { reason });
} else {
this.emit("sync:error", { reason, error: result.error });
}
}
}
export const syncManager = new SyncManager();
// hooks/useSync.ts
import { useState, useEffect } from "react";
import { syncManager } from "@/services/sync-manager";
interface SyncState {
isSyncing: boolean;
lastSyncAt: number | null;
lastError: string | null;
}
export function useSync() {
const [state, setState] = useState<SyncState>({
isSyncing: false,
lastSyncAt: null,
lastError: null,
});
useEffect(() => {
const onStart = () =>
setState((s) => ({ ...s, isSyncing: true, lastError: null }));
const onSuccess = () =>
setState((s) => ({ ...s, isSyncing: false, lastSyncAt: Date.now() }));
const onError = ({ error }: { error: string }) =>
setState((s) => ({ ...s, isSyncing: false, lastError: error }));
syncManager.on("sync:start", onStart);
syncManager.on("sync:success", onSuccess);
syncManager.on("sync:error", onError);
return () => {
syncManager.off("sync:start", onStart);
syncManager.off("sync:success", onSuccess);
syncManager.off("sync:error", onError);
};
}, []);
const manualSync = () => syncManager.triggerSync("manual");
return { ...state, manualSync };
}
Reactive UI with WatermelonDB
// components/TaskList.tsx
import React from "react";
import { FlatList, Text, View, StyleSheet } from "react-native";
import { withObservables } from "@nozbe/with-observables";
import { database } from "@/database";
import { Task } from "@/database/models/Task";
import { Q } from "@nozbe/watermelondb";
import { useSyncIndicator } from "@/hooks/useSync";
interface TaskListProps {
projectId: string;
tasks: Task[]; // Injected by withObservables
}
// Inner component receives reactive tasks array
function TaskListInner({ tasks }: TaskListProps) {
return (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.taskRow}>
<Text style={[styles.title, item.status === "done" && styles.done]}>
{item.title}
</Text>
<Text style={styles.status}>{item.status}</Text>
</View>
)}
/>
);
}
// withObservables subscribes to database changes โ UI updates automatically
const enhance = withObservables(
["projectId"],
({ projectId }: { projectId: string }) => ({
tasks: database
.get<Task>("tasks")
.query(
Q.where("project_id", projectId),
Q.where("is_deleted", false),
Q.sortBy("priority", Q.asc),
Q.sortBy("created_at", Q.asc)
)
.observe(),
})
);
export const TaskList = enhance(TaskListInner);
const styles = StyleSheet.create({
taskRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
title: { flex: 1, fontSize: 15, color: "#111827" },
done: { textDecorationLine: "line-through", color: "#9ca3af" },
status: { fontSize: 12, color: "#6b7280", marginLeft: 8 },
});
Creating Tasks Optimistically
// database/actions/tasks.ts
import { database } from "@/database";
import { Task } from "../models/Task";
interface CreateTaskInput {
title: string;
description?: string;
priority?: number;
projectId: string;
assigneeId?: string;
}
export async function createTask(input: CreateTaskInput): Promise<Task> {
return database.write(async () => {
const task = await database.get<Task>("tasks").create((t) => {
t.title = input.title;
t.description = input.description ?? null;
t.status = "todo";
t.priority = input.priority ?? 0;
t.projectId = input.projectId;
t.assigneeId = input.assigneeId ?? null;
t.isDeleted = false;
t.serverId = null; // Will be set after sync
});
return task;
});
// No API call here โ WatermelonDB queues the change for sync
}
export async function updateTaskStatus(
task: Task,
status: Task["status"]
): Promise<void> {
await database.write(async () => {
await task.update((t) => {
t.status = status;
});
});
}
export async function deleteTask(task: Task): Promise<void> {
await database.write(async () => {
await task.update((t) => {
t.isDeleted = true;
});
});
}
Conflict Resolution Strategies
| Strategy | When to Use | Implementation |
|---|---|---|
| Server wins | Most fields โ authoritative server state | Return remote record in conflictResolver |
| Client wins | Local-only fields (UI preferences, draft content) | Return local record for those fields |
| Last-write-wins | Simple text fields | Compare updated_at timestamps |
| Merge | Structured data (tags, collaborators) | Array union or custom merge logic |
| Manual resolution | Complex conflicts (simultaneous edits to same text) | Surface conflict UI for user to resolve |
For most B2B apps, server wins is correct. The server has the authoritative state after validating business rules.
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic WatermelonDB setup (no sync) | 1 mobile dev | 2โ3 days | $600โ1,200 |
| Offline with full sync protocol | 1โ2 devs (mobile + backend) | 2โ3 weeks | $5,000โ10,000 |
| Multi-device with conflict resolution | 2โ3 devs | 4โ6 weeks | $12,000โ25,000 |
| Enterprise offline (custom conflict UI, audit trail) | 3+ devs | 8โ12 weeks | $25,000โ60,000 |
See Also
- React Native Animations with Reanimated 3
- React Native Navigation with Expo Router
- React Native Gesture Handler: Swipe, Drag, Pinch
- React Query for Server State Management
- SaaS Audit Trail Implementation
Working With Viprasol
Offline-first mobile apps require rethinking every assumption about how data flows โ from instantaneous local writes to multi-device sync to conflict resolution that users never see. Our team has built WatermelonDB-backed apps for field service, healthcare, and logistics customers who need their apps to work without connectivity.
What we deliver:
- WatermelonDB schema design optimized for your data model
- Server-side sync API with change tracking and soft deletes
- Conflict resolution strategy tailored to your data types
- Background sync with network state detection
- Migration system for schema evolution without data loss
Talk to our team about your offline mobile app โ
Or explore our mobile and web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.