React Native Offline-First Architecture: WatermelonDB, Sync
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
> **Quick answer.** Offline-first React Native apps work fully without connectivity by reading from local storage, queuing writes, and syncing bidirectionally when the network returns. Choose the local layer by need: MMKV for key-value, expo-sqlite for relational queries, or WatermelonDB for large reactive datasets. Local reads are sub-millisecond, so it's also faster.
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 1000+ 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 |
Next Steps
- 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
What Viprasol Offers
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.
External Resources
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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.