Skip to content

Mobile Apps

Building Offline-First Mobile Apps with Expo and WatermelonDB

August 19, 20269 min readRasel Hossain
Building Offline-First Mobile Apps with Expo and WatermelonDB

Quick answer

Building offline-first mobile apps with Expo SDK 52 and WatermelonDB means treating the local database as the source of truth, then syncing changes to your server via a two-phase pull/push engine. Combined with background tasks, your app keeps working in tunnels, planes, and rural areas.

Building Offline-First Mobile Apps with Expo and WatermelonDB

Over the last six years building production apps for international clients, I've learned one hard truth: the network is never as reliable as you think. From a delivery driver navigating a basement parking garage to a field researcher working in a remote village, users don't care about your beautiful loading spinners. They care that the app works.

That's why offline-first isn't a buzzword to me — it's a contract with your users. In this guide, I'll walk you through how I build offline-first mobile apps using Expo SDK 52 and WatermelonDB, including the sync engine, conflict resolution, and background sync patterns that survive flaky networks.

building offlinefirst mobile - Image 2

Why Offline-First Matters More Than Ever

When I started building mobile apps back in 2020, the standard approach was: fetch data on app launch, render it, and hope for the best. That worked fine for SaaS dashboards in urban offices. It fell apart the moment I built a logistics app for a client whose drivers operated across rural Bangladesh, where 4G drops to 2G every few kilometers.

building offlinefirst mobile - Image 3

An offline-first app architecture flips the model. Instead of treating the network as the source of truth, the local database is. The network becomes a replication mechanism — a way to keep your local copy in sync with the server when connectivity allows.

The benefits are massive:

  • Instant UX: no loading states for previously viewed data
  • Resilience: works in tunnels, planes, and rural areas
  • Battery efficiency: fewer radio wake-ups
  • Higher engagement: users don't abandon apps that "feel broken"

Why Expo SDK 52 + WatermelonDB?

I've tried most of the offline-first stacks: Redux Persist, Realm, SQLite directly, Firebase, and even custom IndexedDB wrappers. For React Native in 2024, my default stack is:

  • Expo SDK 52 for the build pipeline, EAS Update for OTA, and expo-task-manager + expo-background-fetch for background work
  • WatermelonDB as the local reactive database — it's built specifically for offline-first sync and outperforms SQLite-based solutions by 5-10x in writes
  • A custom sync engine (no, I don't use WatermelonDB's deprecated sync helper — I'll show you a better pattern)

WatermelonDB is lazy by design, uses JSI for native performance, and has a synchronize primitive that makes building bidirectional sync straightforward. Combined with Expo's managed workflow, you get OTA updates, background tasks, and a development experience that doesn't make me want to throw my laptop.

Setting Up the Project

Let's bootstrap a fresh project. I'm assuming you have Expo CLI installed and Node 20+.

npx create-expo-app@latest offline-first-demo --template blank-typescript
cd offline-first-demo
npx expo install expo-sqlite expo-task-manager expo-background-fetch expo-network
npm install @nozbe/watermelondb @nozbe/with-observables
npm install --save-dev @babel/plugin-proposal-decorators

WatermelonDB relies on legacy decorators, so update your babel.config.js:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      ['@babel/plugin-proposal-decorators', { legacy: true }],
    ],
  };
};

Because WatermelonDB needs JSI and a native module, we need a development build. The Expo Go app won't work here.

npx expo prebuild
npx expo run:ios
# or
npx expo run:android

Designing the Data Model

A good offline-first app starts with a clear schema. Here's a simplified task tracker I built for a client — every task belongs to a project, has a status, and supports offline edits.

// model/Project.ts
import { Model } from '@nozbe/watermelondb';
import { field, children, date, readonly } from '@nozbe/watermelondb/decorators';

export default class Project extends Model {
  static table = 'projects';
  static associations = {
    tasks: { type: 'has_many', foreignKey: 'project_id' },
  };

  @field('name') name!: string;
  @field('server_id') serverId!: string;
  @date('updated_at') updatedAt!: number;
  @readonly @date('created_at') createdAt!: number;
  @children('tasks') tasks!: any;
}
// model/Task.ts
import { Model } from '@nozbe/watermelondb';
import { field, relation, date, readonly, writer } from '@nozbe/watermelondb/decorators';

export default class Task extends Model {
  static table = 'tasks';
  static associations = {
    projects: { type: 'belongs_to', key: 'project_id' },
  };

  @field('title') title!: string;
  @field('status') status!: 'pending' | 'in_progress' | 'done';
  @field('server_id') serverId?: string;
  @field('is_dirty') isDirty!: boolean;
  @field('is_deleted') isDeleted!: boolean;
  @relation('projects', 'project_id') project!: any;
  @date('updated_at') updatedAt!: number;
  @readonly @date('created_at') createdAt!: number;

  @writer async markDone() {
    await this.update((task) => {
      task.status = 'done';
      task.isDirty = true;
    });
  }
}

Notice the two critical fields: is_dirty and is_deleted. These are the foundation of our sync engine. Every local write flips isDirty = true, and we never actually delete — we soft-delete with is_deleted so the change can be replicated to the server.

Initializing the Database

// model/database.ts
import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { schema } from './schema';
import Project from './Project';
import Task from './Task';

const adapter = new SQLiteAdapter({
  schema,
  jsi: true,
  onSetUpError: (error) => {
    console.error('Database failed to load', error);
  },
});

export const database = new Database({
  adapter,
  modelClasses: [Project, Task],
});

The Sync Engine: Pull + Push

This is where most teams get it wrong. They try to do everything in one transaction and end up with race conditions. The pattern that actually works is two separate phases: pull (server → local) and push (local → server), executed sequentially.

// sync/syncEngine.ts
import { database } from '../model/database';
import NetInfo from '@react-native-community/netinfo';
import api from './api';

export async function sync() {
  const netState = await NetInfo.fetch();
  if (!netState.isConnected) return { skipped: true };

  await pushChanges();
  await pullChanges();

  return { syncedAt: Date.now() };
}

async function pushChanges() {
  const dirtyTasks = await database
    .get('tasks')
    .query(Q.where('is_dirty', true))
    .fetch();

  if (dirtyTasks.length === 0) return;

  const payload = dirtyTasks.map((task) => ({
    clientId: task.id,
    serverId: task.serverId,
    title: task.title,
    status: task.status,
    isDeleted: task.isDeleted,
    projectId: task.project.id,
    updatedAt: task.updatedAt,
  }));

  const response = await api.post('/sync/push', { changes: payload });

  await database.write(async () => {
    for (const result of response.results) {
      const task = await database.get('tasks').find(result.clientId);
      await task.update((t) => {
        t.serverId = result.serverId;
        t.isDirty = false;
        if (result.deletedAt) t.isDeleted = true;
      });
    }
  });
}

async function pullChanges() {
  const lastPulledAt = await getLastPulledAt();
  const response = await api.get('/sync/pull', {
    params: { since: lastPulledAt },
  });

  await database.write(async () => {
    for (const change of response.changes) {
      const existing = await findLocalByServerId('tasks', change.serverId);
      if (change.deleted) {
        if (existing) await existing.destroyPermanently();
        continue;
      }
      if (existing) {
        await existing.update((t) => {
          t.title = change.title;
          t.status = change.status;
          // ...
        });
      } else {
        await database.get('tasks').create((t) => {
          t.serverId = change.serverId;
          t.title = change.title;
          t.status = change.status;
          t.isDirty = false;
        });
      }
    }
  });

  await setLastPulledAt(response.now);
}

Conflict Resolution: Last-Write-Wins vs CRDTs

Conflicts are inevitable. Two devices edit the same task while offline. When they reconnect, which version wins?

For 90% of apps I've shipped, server-side last-write-wins based on updatedAt timestamp is the right answer. It's predictable, debuggable, and good enough. Your sync API should accept the client's updatedAt and reject changes older than the server's current version.

// Server-side pseudocode
app.post('/sync/push', async (req, res) => {
  const results = [];
  for (const change of req.body.changes) {
    const existing = await db.tasks.findOne({ serverId: change.serverId });
    if (!existing) {
      const created = await db.tasks.create(change);
      results.push({ clientId: change.clientId, serverId: created.id });
    } else if (new Date(change.updatedAt) > existing.updatedAt) {
      await existing.update(change);
      results.push({ clientId: change.clientId, serverId: existing.id });
    } else {
      // Conflict: server version is newer, push it back to client
      results.push({ clientId: change.clientId, serverId: existing.id, conflict: existing });
    }
  }
  res.json({ results });
});

For collaborative apps (think Figma, Notion), you'll need CRDTs like Yjs or Automerge. But that's a different architecture entirely — start with timestamps.

Background Sync with Expo Tasks

The killer feature of offline-first is truly running in the background. Expo SDK 52's expo-task-manager combined with expo-background-fetch lets you sync even when the app is closed.

// sync/backgroundSync.ts
import * as TaskManager from 'expo-task-manager';
import * as BackgroundFetch from 'expo-background-fetch';
import { sync } from './syncEngine';

const BACKGROUND_SYNC_TASK = 'background-sync';

TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
  try {
    const result = await sync();
    return result.skipped
      ? BackgroundFetch.BackgroundFetchResult.NoData
      : BackgroundFetch.BackgroundFetchResult.NewData;
  } catch (error) {
    return BackgroundFetch.BackgroundFetchResult.Failed;
  }
});

export async function registerBackgroundSync() {
  return BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
    minimumInterval: 15, // minutes — Android minimum
    stopOnTerminate: false,
    startOnBoot: true,
  });
}

Call registerBackgroundSync() once after the user logs in. iOS won't always honor the interval, but Android is more reliable. For real-time sync, also trigger sync() whenever the app comes to foreground via AppState.

Practical Tips From Production

After shipping this pattern to multiple clients, here are the lessons I wish I'd known earlier:

  1. Never sync on app launch. Wait until your UI is rendered and the user sees something. Cold-start latency is precious.
  2. Batch your push requests. Sending 200 individual POSTs will burn battery and hit rate limits. Bundle them.
  3. Use expo-sqlite encryption for sensitive data. WatermelonDB supports db.sqlite encryption via SQLCipher — your fintech clients will thank you.
  4. Version your sync schema. When you change a column, ship a migration. WatermelonDB's schema migrations are first-class — use them.
  5. Persist failed syncs. If the push fails after 3 retries, queue it for next time. Don't lose user data.
  6. Show sync status in the UI. A small badge like "3 changes pending" builds enormous trust.

Testing the Offline Path

I cannot stress this enough: test offline. Use NetInfo to simulate:

await NetInfo.fetch().then((state) => {
  console.log('Connection type:', state.type);
  console.log('Is connected?', state.isConnected);
});

In your dev tools, throttle to "Slow 3G" and toggle airplane mode. Watch your app's behavior. Most "offline-first" apps I've audited actually break when you turn off Wi-Fi — the auth check fails, the sync crashes, the UI shows a generic error.

Conclusion

Building offline-first mobile apps with Expo and WatermelonDB isn't just a technical choice — it's a UX commitment. When your app works in a basement, on a plane, or in a village with one bar of signal, users notice. They stay. They pay.

I've used this exact stack on logistics apps, healthcare apps, and field-research apps for international clients. It scales, it's maintainable, and it survives real-world conditions. If you're building a React Native app that needs to work everywhere, start offline-first.

Need help architecting your offline-first app or building a custom sync engine? I've shipped this pattern for clients across three continents — let's talk about your project.

#React Native#Mobile Development#Offline-First#Expo#WatermelonDB

How to do it

  1. 1

    Set up Expo SDK 52 with WatermelonDB and native dependencies

    Bootstrap a TypeScript Expo project with `npx create-expo-app`, then install WatermelonDB, expo-sqlite, expo-task-manager, expo-background-fetch, and expo-network. Configure babel.config.js to support legacy decorators required by WatermelonDB. Run `npx expo prebuild` and `npx expo run:ios` (or `run:android`) to generate a development build, since WatermelonDB requires JSI and native modules that Expo Go cannot provide.

  2. 2

    Define your WatermelonDB schema and reactive models

    Create your schema definition listing tables and columns. Then build Model classes with @field, @relation, @children, and @date decorators. Add critical sync-tracking fields: `server_id` (to map local records to remote ones), `is_dirty` (to flag records needing push), and `is_deleted` (for soft-delete replication). Use @writer methods to ensure all mutations correctly set isDirty=true.

  3. 3

    Implement local-first CRUD operations with WatermelonDB

    Initialize the SQLiteAdapter with jsi: true and your schema. Use `database.write()` blocks for mutations and `.query(Q.where(...)).fetch()` for reads. Wrap your UI components with `withObservables` from `@nozbe/with-observables` so they re-render automatically when local data changes — this gives you the instant, reactive UX that makes offline-first feel magical.

  4. 4

    Build the sync engine with pull and push phases

    Create a sync module that checks network connectivity with NetInfo, then runs pushChanges() followed by pullChanges(). Push bundles all isDirty=true records into one POST and updates their server_id once acknowledged. Pull fetches server changes since the last `lastPulledAt` timestamp and applies them locally with create/update/destroyPermanently operations. Persist lastPulledAt to track sync progress.

  5. 5

    Configure background sync and foreground triggers

    Use TaskManager.defineTask to register a background-sync task, then BackgroundFetch.registerTaskAsync with minimumInterval: 15 (Android minimum). Set stopOnTerminate: false and startOnBoot: true so sync survives reboots. Also hook into AppState's 'active' event to trigger an immediate sync whenever the user returns to the app, ensuring changes appear without delay.

Frequently asked questions

What is an offline-first mobile app?

An offline-first mobile app treats the local device database as the source of truth rather than the remote server. The network becomes a replication mechanism. Users can create, read, update, and delete data without an internet connection, and changes sync to the server whenever connectivity returns. This delivers instant UX, works in low-signal environments, and dramatically improves user retention in markets with flaky networks.

Why use WatermelonDB with Expo for offline-first apps?

WatermelonDB is purpose-built for offline-first React Native apps. It uses JSI for native-grade performance (5-10x faster than SQLite-based alternatives for writes), has a reactive query layer that integrates cleanly with React, supports schema migrations, and provides primitives like `synchronize` that make building bidirectional sync straightforward. Combined with Expo SDK 52's background tasks and EAS Update, you get a fully managed offline-first pipeline without ejecting.

How do you handle conflict resolution in offline-first apps?

For most apps, server-side last-write-wins based on a synchronized `updatedAt` timestamp is the right conflict resolution strategy. Each record carries a timestamp; the server rejects changes older than the current version and pushes its newer version back to the client. For collaborative apps like Figma or Notion, you'll need CRDTs such as Yjs or Automerge. Start with timestamps — they're predictable, debuggable, and sufficient for 90% of use cases.

How does background sync work in Expo SDK 52?

Expo SDK 52 provides `expo-task-manager` and `expo-background-fetch` for background work. You define a sync task using `TaskManager.defineTask()`, register it with `BackgroundFetch.registerTaskAsync()` with a minimum interval (15 minutes on Android), and the OS will wake your app periodically to run the sync. iOS intervals are best-effort, but Android is reliable. Always pair background sync with foreground sync on `AppState` change for instant updates when the user reopens the app.

What are the common pitfalls when building offline-first apps?

The most common pitfalls are: (1) syncing on app launch before the UI renders, causing perceived slowness; (2) not batching push requests, which drains battery; (3) failing to persist sync failures, causing data loss; (4) hard-deleting records instead of soft-deleting, which breaks replication; (5) forgetting to migrate the schema when columns change; and (6) not testing airplane mode rigorously. Always show pending sync status in the UI to build user trust.

More articles

Related reading from the same areas — practical notes on shipping software.

View all articles

Liked the article?

Have a similar problem in your business? Let's talk about building the fix.

Start a project