Back to Blog

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.

Viprasol Tech Team
September 5, 2026
13 min read

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

OptionUse CaseQuery PowerSync SupportPerformance
MMKVSimple key-value (settings, tokens)NoneManualFastest
AsyncStorageSimple key-value (legacy)NoneManualSlow
SQLite (expo-sqlite)Relational queries, small datasetsSQLManualGood
WatermelonDBRelational + reactive queriesSQLBuilt-in protocolExcellent
RealmObject-oriented, real-time syncRQLCRealm Sync (paid)Excellent
MMKV + TanStack QueryAPI cache + optimistic UINoneVia serverGreat

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

OptimizationImpact
Enable JSI mode (jsi: true)3-5x faster queries
Index frequently queried columns10-100x faster filtering
Use .observe() not .fetch() in componentsReactive, no polling
Batch writes in database.write()Single transaction vs N transactions
Lazy-load relations (@lazy)Load relations on demand
Paginate large listsAvoid loading 10k rows at once

Cost Comparison: Storage Options

SolutionSetup EffortSync CostOffline SupportScalability
WatermelonDB + custom syncMediumYour server costsFullHigh
Realm + Realm SyncLow$25โ€“$499/mo (Atlas)FullHigh
Supabase RealtimeLow$25โ€“$599/moPartial (cache)High
Firebase FirestoreLow$25โ€“$500/moFullHigh
Custom SQLite + RESTHighYour server costsFullHigh

See Also


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.

Mobile development services โ†’ | Start a project โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.