React Native Offline-First Architecture: WatermelonDB, Sync, and Conflict Resolution
Build offline-first React Native apps with WatermelonDB, automatic sync pipelines, conflict resolution strategies, and network-aware UX patterns that work without connectivity.
Most mobile apps treat connectivity as a prerequisite. The app shows a spinner, waits for the network, and displays an error when it times out. This is the wrong model for mobile.
Offline-first means the app works fully without connectivity โ reading from local storage, queuing writes, and syncing bidirectionally when connectivity returns. Done right, it's also faster than a network-first app, because local reads are sub-millisecond.
Choosing a Local Storage Layer
| Option | Use Case | Query Power | Sync Support | Performance |
|---|---|---|---|---|
| MMKV | Simple key-value (settings, tokens) | None | Manual | Fastest |
| AsyncStorage | Simple key-value (legacy) | None | Manual | Slow |
| SQLite (expo-sqlite) | Relational queries, small datasets | SQL | Manual | Good |
| WatermelonDB | Relational + reactive queries | SQL | Built-in protocol | Excellent |
| Realm | Object-oriented, real-time sync | RQLC | Realm Sync (paid) | Excellent |
| MMKV + TanStack Query | API cache + optimistic UI | None | Via server | Great |
WatermelonDB is the right choice for apps with complex relational data that needs sync. It runs SQLite under the hood with a lazy-loading reactive layer.
WatermelonDB Setup
npm install @nozbe/watermelondb
npm install @nozbe/with-observables
# Native SQLite adapter
npx expo install expo-sqlite
Schema Definition
// src/db/schema.ts
import { appSchema, tableSchema } from "@nozbe/watermelondb";
export const schema = appSchema({
version: 5,
tables: [
tableSchema({
name: "projects",
columns: [
{ name: "remote_id", type: "string", isOptional: true, isIndexed: true },
{ name: "name", type: "string" },
{ name: "description", type: "string", isOptional: true },
{ name: "status", type: "string" },
{ name: "owner_id", type: "string", isIndexed: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
{ name: "is_deleted", type: "boolean" },
{ name: "synced_at", type: "number", isOptional: true },
],
}),
tableSchema({
name: "tasks",
columns: [
{ name: "remote_id", type: "string", isOptional: true, isIndexed: true },
{ name: "project_id", type: "string", isIndexed: true },
{ name: "title", type: "string" },
{ name: "description", type: "string", isOptional: true },
{ name: "status", type: "string" },
{ name: "assignee_id", type: "string", isOptional: true },
{ name: "due_date", type: "number", isOptional: true },
{ name: "created_at", type: "number" },
{ name: "updated_at", type: "number" },
{ name: "is_deleted", type: "boolean" },
{ name: "synced_at", type: "number", isOptional: true },
],
}),
],
});
Model Definitions
// src/db/models/Project.ts
import { Model, Q } from "@nozbe/watermelondb";
import {
field,
date,
children,
readonly,
action,
lazy,
} from "@nozbe/watermelondb/decorators";
import type { Relation, Query } from "@nozbe/watermelondb";
import type Task from "./Task";
export default class Project extends Model {
static table = "projects";
static associations = {
tasks: { type: "has_many" as const, foreignKey: "project_id" },
};
@field("remote_id") remoteId!: string | null;
@field("name") name!: string;
@field("description") description!: string | null;
@field("status") status!: string;
@field("owner_id") ownerId!: string;
@readonly @date("created_at") createdAt!: Date;
@date("updated_at") updatedAt!: Date;
@field("is_deleted") isDeleted!: boolean;
@date("synced_at") syncedAt!: Date | null;
@children("tasks") tasks!: Query<Task>;
@lazy activeTasks = this.tasks.extend(
Q.where("is_deleted", false),
Q.where("status", Q.notEq("completed"))
);
@action async markDeleted() {
await this.update((project) => {
project.isDeleted = true;
project.updatedAt = new Date();
});
}
}
Database Initialization
// src/db/index.ts
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { schema } from "./schema";
import migrations from "./migrations";
import Project from "./models/Project";
import Task from "./models/Task";
const adapter = new SQLiteAdapter({
schema,
migrations,
jsi: true, // JSI mode for 3-5x faster queries
onSetUpError: (error) => {
console.error("WatermelonDB setup error:", error);
// In production: report to Sentry, show error screen
},
});
export const database = new Database({
adapter,
modelClasses: [Project, Task],
});
export const projectsCollection = database.get<Project>("projects");
export const tasksCollection = database.get<Task>("tasks");
๐ 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
Sync Engine
WatermelonDB uses a pull-push sync protocol. You implement the server side:
// src/services/sync.service.ts
import { synchronize } from "@nozbe/watermelondb/sync";
import { database } from "@/db";
import { getAuthToken } from "@/lib/auth";
interface SyncChanges {
created: Record<string, unknown>[];
updated: Record<string, unknown>[];
deleted: string[];
}
interface SyncPullResult {
changes: {
projects: SyncChanges;
tasks: SyncChanges;
};
timestamp: number;
}
interface SyncPushPayload {
changes: {
projects: SyncChanges;
tasks: SyncChanges;
};
lastPulledAt: number | null;
}
export async function syncDatabase(): Promise<void> {
const token = await getAuthToken();
await synchronize({
database,
// Pull: fetch changes from server since last sync
pullChanges: async ({ lastPulledAt }) => {
const response = await fetch(
`${process.env.EXPO_PUBLIC_API_URL}/sync/pull?lastPulledAt=${lastPulledAt ?? 0}`,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Sync pull failed: ${response.status}`);
}
const result = (await response.json()) as SyncPullResult;
return result;
},
// Push: send local changes to server
pushChanges: async ({ changes, lastPulledAt }) => {
const response = await fetch(
`${process.env.EXPO_PUBLIC_API_URL}/sync/push`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ changes, lastPulledAt } as SyncPushPayload),
}
);
if (!response.ok) {
const body = await response.json();
// Handle conflict errors specially
if (response.status === 409) {
throw new SyncConflictError(body.conflicts);
}
throw new Error(`Sync push failed: ${response.status}`);
}
},
// Migration strategy for schema version mismatches
migrationsEnabledAtVersion: 1,
});
}
Server-Side Sync Endpoints (Next.js)
// src/app/api/sync/pull/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const lastPulledAt = Number(searchParams.get("lastPulledAt")) || 0;
const userId = await getUserIdFromToken(request);
const sinceDate = new Date(lastPulledAt);
const now = Date.now();
// Fetch all changes since lastPulledAt
const [projectChanges, taskChanges] = await Promise.all([
getProjectChanges(userId, sinceDate),
getTaskChanges(userId, sinceDate),
]);
return NextResponse.json({
changes: {
projects: projectChanges,
tasks: taskChanges,
},
timestamp: now,
});
}
async function getProjectChanges(userId: string, since: Date) {
const { rows: created } = await db.query(
`SELECT * FROM projects
WHERE owner_id = $1
AND created_at > $2
AND synced_at IS NULL OR synced_at < created_at`,
[userId, since]
);
const { rows: updated } = await db.query(
`SELECT * FROM projects
WHERE owner_id = $1
AND updated_at > $2
AND created_at <= $2`,
[userId, since]
);
const { rows: deleted } = await db.query(
`SELECT id FROM projects
WHERE owner_id = $1
AND is_deleted = true
AND updated_at > $2`,
[userId, since]
);
return {
created: created.map(toWatermelonFormat),
updated: updated.map(toWatermelonFormat),
deleted: deleted.map((r: { id: string }) => r.id),
};
}
// src/app/api/sync/push/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const { changes, lastPulledAt } = await request.json();
const userId = await getUserIdFromToken(request);
// Process push in a transaction
await db.transaction(async (client) => {
// Handle creates
for (const record of changes.projects?.created ?? []) {
await client.query(
`INSERT INTO projects (id, name, description, status, owner_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING`,
[record.id, record.name, record.description, record.status, userId,
new Date(record.created_at), new Date(record.updated_at)]
);
}
// Handle updates โ conflict detection
for (const record of changes.projects?.updated ?? []) {
const { rows } = await client.query(
"SELECT updated_at FROM projects WHERE id = $1",
[record.id]
);
if (rows.length > 0) {
const serverUpdatedAt = new Date(rows[0].updated_at).getTime();
const clientUpdatedAt = record.updated_at;
if (serverUpdatedAt > lastPulledAt && clientUpdatedAt > lastPulledAt) {
// Conflict: both server and client modified since last sync
// Default strategy: server wins (last-write-wins by server)
// Alternative: return 409 and let client handle merge
}
}
await client.query(
`UPDATE projects
SET name = $2, description = $3, status = $4, updated_at = $5
WHERE id = $1 AND owner_id = $6`,
[record.id, record.name, record.description, record.status,
new Date(record.updated_at), userId]
);
}
// Handle soft deletes
for (const id of changes.projects?.deleted ?? []) {
await client.query(
"UPDATE projects SET is_deleted = true WHERE id = $1 AND owner_id = $2",
[id, userId]
);
}
});
return NextResponse.json({ ok: true });
}
Conflict Resolution Strategies
Three strategies, each appropriate for different data types:
// src/services/conflict-resolver.ts
type ConflictStrategy = "last-write-wins" | "merge-fields" | "user-choice";
interface ConflictRecord {
id: string;
localVersion: Record<string, unknown>;
serverVersion: Record<string, unknown>;
localUpdatedAt: number;
serverUpdatedAt: number;
}
// Strategy 1: Last-Write-Wins (LWW) โ good for status fields
export function resolveLastWriteWins(conflict: ConflictRecord) {
return conflict.localUpdatedAt > conflict.serverUpdatedAt
? conflict.localVersion
: conflict.serverVersion;
}
// Strategy 2: Field-level merge โ good for documents
export function resolveFieldMerge(conflict: ConflictRecord) {
const merged = { ...conflict.serverVersion };
// For each field, take the version with the later update
// This requires field-level timestamps (more complex schema)
for (const key of Object.keys(conflict.localVersion)) {
const localFieldUpdated = (conflict.localVersion as Record<string, { value: unknown; updatedAt: number }>)[key]?.updatedAt ?? 0;
const serverFieldUpdated = (conflict.serverVersion as Record<string, { value: unknown; updatedAt: number }>)[key]?.updatedAt ?? 0;
if (localFieldUpdated > serverFieldUpdated) {
merged[key] = conflict.localVersion[key];
}
}
return merged;
}
// Strategy 3: CRDTs for collaborative data (e.g., lists, counters)
// Use a library like Automerge or Yjs for full CRDT support
export async function resolveWithCRDT(
localDoc: Uint8Array,
serverDoc: Uint8Array
): Promise<Uint8Array> {
const { Automerge } = await import("@automerge/automerge");
const merged = Automerge.merge(
Automerge.load(localDoc),
Automerge.load(serverDoc)
);
return Automerge.save(merged);
}
๐ 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
Network-Aware UX
// src/hooks/useNetworkSync.ts
import { useEffect, useRef } from "react";
import NetInfo from "@react-native-community/netinfo";
import { syncDatabase } from "@/services/sync.service";
import { useSyncStore } from "@/stores/sync.store";
export function useNetworkSync() {
const { setSyncing, setLastSyncedAt, setError } = useSyncStore();
const syncInProgress = useRef(false);
const performSync = async () => {
if (syncInProgress.current) return;
syncInProgress.current = true;
setSyncing(true);
try {
await syncDatabase();
setLastSyncedAt(new Date());
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : "Sync failed");
} finally {
syncInProgress.current = false;
setSyncing(false);
}
};
useEffect(() => {
// Sync on connectivity restore
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected && state.isInternetReachable) {
performSync();
}
});
// Sync on mount if connected
NetInfo.fetch().then((state) => {
if (state.isConnected) performSync();
});
return unsubscribe;
}, []);
}
// src/components/SyncStatusBar.tsx
import { View, Text } from "react-native";
import { useSyncStore } from "@/stores/sync.store";
import NetInfo from "@react-native-community/netinfo";
import { useNetInfo } from "@react-native-community/netinfo";
export function SyncStatusBar() {
const { isSyncing, lastSyncedAt, error } = useSyncStore();
const netInfo = useNetInfo();
const isOffline = !netInfo.isConnected;
if (isOffline) {
return (
<View className="bg-amber-500 px-4 py-1 flex-row items-center justify-center">
<Text className="text-white text-xs font-medium">
Offline โ changes will sync when connected
</Text>
</View>
);
}
if (error) {
return (
<View className="bg-red-500 px-4 py-1 flex-row items-center justify-center">
<Text className="text-white text-xs">Sync error โ tap to retry</Text>
</View>
);
}
if (isSyncing) {
return (
<View className="bg-blue-500 px-4 py-1 flex-row items-center justify-center gap-2">
<ActivityIndicator size="small" color="white" />
<Text className="text-white text-xs">Syncingโฆ</Text>
</View>
);
}
if (lastSyncedAt) {
return (
<View className="bg-green-500 px-4 py-1 flex-row items-center justify-center">
<Text className="text-white text-xs">
Synced {formatRelativeTime(lastSyncedAt)}
</Text>
</View>
);
}
return null;
}
Reactive Queries with withObservables
WatermelonDB's killer feature: React components re-render automatically when underlying data changes.
// src/components/ProjectList.tsx
import { withObservables } from "@nozbe/with-observables";
import { Q } from "@nozbe/watermelondb";
import { projectsCollection } from "@/db";
import type Project from "@/db/models/Project";
// Inner component โ receives reactive data as props
function ProjectListInner({ projects }: { projects: Project[] }) {
return (
<FlatList
data={projects}
keyExtractor={(p) => p.id}
renderItem={({ item }) => <ProjectCard project={item} />}
/>
);
}
// Enhance with observable query โ auto re-renders on data change
const enhance = withObservables([], () => ({
projects: projectsCollection
.query(
Q.where("is_deleted", false),
Q.sortBy("updated_at", Q.desc)
)
.observe(),
}));
export const ProjectList = enhance(ProjectListInner);
Performance Tips
| Optimization | Impact |
|---|---|
Enable JSI mode (jsi: true) | 3-5x faster queries |
| Index frequently queried columns | 10-100x faster filtering |
Use .observe() not .fetch() in components | Reactive, no polling |
Batch writes in database.write() | Single transaction vs N transactions |
Lazy-load relations (@lazy) | Load relations on demand |
| Paginate large lists | Avoid loading 10k rows at once |
Cost Comparison: Storage Options
| Solution | Setup Effort | Sync Cost | Offline Support | Scalability |
|---|---|---|---|---|
| WatermelonDB + custom sync | Medium | Your server costs | Full | High |
| Realm + Realm Sync | Low | $25โ$499/mo (Atlas) | Full | High |
| Supabase Realtime | Low | $25โ$599/mo | Partial (cache) | High |
| Firebase Firestore | Low | $25โ$500/mo | Full | High |
| Custom SQLite + REST | High | Your server costs | Full | High |
See Also
- React Native Testing Strategies โ testing offline behavior
- React Native Navigation Patterns โ app navigation
- WebSockets vs Server-Sent Events โ real-time sync options
- Caching Strategies for High-Traffic Apps โ caching architecture
Working With Viprasol
Offline-first mobile architecture is one of the most technically demanding problems in app development โ and one of the highest-impact investments for user experience. Our mobile engineers have built sync engines for field service apps, trading platforms, and logistics tools that work reliably in zero-connectivity environments.
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.