Skip to main content
  1. Posts/

Offline-first sync in React Native with WatermelonDB and background fetch

·4 mins

If your app depends on remote data but you want to give users a fast, responsive experience — even when offline — you need an offline-first architecture.

In this post, we’ll build a simplified offline-first setup in React Native using:

  • WatermelonDB for high-performance local storage.
  • react-native-background-fetch to handle background synchronization.
  • A fake REST API to demonstrate sync flow and conflict resolution.

Let’s get into it.

Why offline-first? #

Mobile networks are unpredictable. Users expect things to work instantly, even without Wi-Fi or 4G. That’s why many apps — from Notion to WhatsApp — implement offline-first designs.

Benefits:

  • Instant data access.
  • Graceful degradation when offline.
  • Seamless sync when connectivity returns.
  • Better user satisfaction.

The tech stack #

WatermelonDB #

WatermelonDB is a reactive database optimized for large-scale React Native apps. It uses SQLite under the hood and works well with large datasets. Crucially, it’s fast, supports lazy loading, and is designed for offline-first apps.

Background fetch #

We’ll use react-native-background-fetch to periodically wake up our app (even in the background) and check for sync opportunities.

Project setup #

<!— prettier-ignore-start —>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
npx create-expo-app offline-sync-demo cd offline-sync-demo

# Install WatermelonDB

npm install @nozbe/watermelondb @nozbe/with-observables npm install —save
react-native-sqlite-storage

# For background fetch

npm install react-native-background-fetch
<!— prettier-ignore-end —>

WatermelonDB schema & model #

Let’s say we’re managing a simple list of tasks.

<!— prettier-ignore-start —>

1
2
3
4
5
6
7
// src/model/schema.ts import { appSchema, tableSchema } from
@nozbe/watermelondb;

export const mySchema = appSchema({ version: 1, tables: [ tableSchema({ name:
tasks, columns: [ { name: title, type: string }, { name: completed,
type: boolean }, { name: synced, type: boolean }, { name: updated_at,
type: number }, ], }), ], });
<!— prettier-ignore-end —>

And the model:

<!— prettier-ignore-start —>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// src/model/Task.ts import { Model } from ‘@nozbe/watermelondb’; import {
field, writer } from @nozbe/watermelondb/decorators;

export class Task extends Model { static table = tasks;

@field(title) title!: string; @field(completed) completed!: boolean;
@field(synced) synced!: boolean; @field(updated_at) updatedAt!: number;

@writer async markCompleted() { await this.update((task) => { task.completed =
true; task.updatedAt = Date.now(); task.synced = false; }); } }
<!— prettier-ignore-end —>

Sync logic: push and pull #

Let’s assume our backend exposes two endpoints:

  • GET /tasks?since=<timestamp> — to pull new tasks
  • POST /tasks/sync — to push local unsynced tasks

Here’s the full sync function:

<!— prettier-ignore-start —>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/sync.ts import database from ‘./model/database’; import { Task } from
./model/Task; import axios from axios;

export async function syncWithServer() { const tasksCollection =
database.get<Task>(tasks);

// Pull updates from server const lastUpdated = Date.now() - 1000 _ 60 _ 60; //
just for demo const { data: remoteTasks } = await
axios.get(`https://my-api.com/tasks?since=${lastUpdated}`);

await database.write(async () => { for (const task of remoteTasks) { await
tasksCollection.create((newTask) => { newTask.\_raw.id = task.id; // force ID
for deduplication newTask.title = task.title; newTask.completed =
task.completed; newTask.synced = true; newTask.updatedAt = task.updated_at; });
} });

// Push local unsynced tasks const unsyncedToPush = await
tasksCollection.query(Q.where(synced, false)).fetch();

await axios.post(`https://my-api.com/tasks/sync`, { tasks:
unsyncedToPush.map((t) => ({ id: t.id, title: t.title, completed: t.completed,
updated_at: t.updatedAt, })), });

// Mark them as synced await database.write(async () => { for (const task of
unsyncedToPush) { await task.update((t) => { t.synced = true; }); } }); }
<!— prettier-ignore-end —>

Background sync with react-native-background-fetch #

Configure the background task:

<!— prettier-ignore-start —>

1
2
3
4
5
6
7
8
9
// src/background.ts import BackgroundFetch from
react-native-background-fetch; import { syncWithServer } from ./sync;

export async function initBackgroundSync() { await BackgroundFetch.configure( {
minimumFetchInterval: 15, // every 15 minutes stopOnTerminate: false,
startOnBoot: true, }, async () => { console.log([BackgroundFetch] triggered);
await syncWithServer();
BackgroundFetch.finish(BackgroundFetch.FetchResult.NewData); }, (error) => {
console.warn([BackgroundFetch] failed to start, error); } ); }
<!— prettier-ignore-end —>

Then call initBackgroundSync() when the app starts (e.g. in App.tsx).

You must configure native code in Xcode and AndroidManifest to support background tasks properlicheck the official docs:
https://github.com/transistorsoft/react-native-background-fetch

Conflict resolution strategies #

What happens if both client and server updated the same task?

Some options:

  • Use timestamps: latest update wins.
  • Prompt user: show a UI for manual resolution.
  • Merge changes: only override changed fields.

In our example, we use updated_at to decide which version wins.

Final thoughts #

Building offline-first apps in React Native isn’t trivial — but it’s incredibly powerful. With WatermelonDB, your local data is fast, scalable, and persistent. Add background sync and your app behaves like a native offline-first powerhouse.

You’re now ready to build apps that users can trust, even when the network can’t be.