Back to Blog

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.

Viprasol Tech Team
March 11, 2027
14 min read

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

StrategyWhen to UseImplementation
Server winsMost fields โ€” authoritative server stateReturn remote record in conflictResolver
Client winsLocal-only fields (UI preferences, draft content)Return local record for those fields
Last-write-winsSimple text fieldsCompare updated_at timestamps
MergeStructured data (tags, collaborators)Array union or custom merge logic
Manual resolutionComplex 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

ScopeTeamTimelineCost Range
Basic WatermelonDB setup (no sync)1 mobile dev2โ€“3 days$600โ€“1,200
Offline with full sync protocol1โ€“2 devs (mobile + backend)2โ€“3 weeks$5,000โ€“10,000
Multi-device with conflict resolution2โ€“3 devs4โ€“6 weeks$12,000โ€“25,000
Enterprise offline (custom conflict UI, audit trail)3+ devs8โ€“12 weeks$25,000โ€“60,000

See Also


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.

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.