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.
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.
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-fetchfor 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:
- Never sync on app launch. Wait until your UI is rendered and the user sees something. Cold-start latency is precious.
- Batch your push requests. Sending 200 individual POSTs will burn battery and hit rate limits. Bundle them.
- Use
expo-sqliteencryption for sensitive data. WatermelonDB supportsdb.sqliteencryption viaSQLCipher— your fintech clients will thank you. - Version your sync schema. When you change a column, ship a migration. WatermelonDB's schema migrations are first-class — use them.
- Persist failed syncs. If the push fails after 3 retries, queue it for next time. Don't lose user data.
- 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.