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 tasksPOST /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.