Back to Blog

React Native Offline-First Apps: Complete Guide with WatermelonDB

Build React Native apps that work without internet. WatermelonDB setup, sync queues, conflict resolution, network detection, and background sync — with full working code.

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 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

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],
});
React Native - React Native Offline-First Apps: Complete Guide with WatermelonDB

🚀 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.

Budget Breakdown

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

Explore More


What We Bring to the Table

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.

React NativeOfflineWatermelonDBSyncTypeScriptMobile
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.