You know that moment when you’re in the middle of nowhere, your 4G turns into “E”, and your mobile app decides to become a very expensive paperweight? Yeah. That’s exactly why I wrote this. Picture a delivery driver staring at a loading spinner because the app can’t load their route without internet. Or a warehouse manager unable to pull the inventory list because the server’s down. That’s not “quirky behavior,” that’s lost time, lost money, and very angry users. A bunch of our clients hit these connectivity potholes — remote job sites, offsite events, warehouse floors, logistics hubs. Basically, anywhere Wi-Fi goes to die. The fix? Local-first apps: process data locally, sync it when you can. Your users stay happy, and you don’t get midnight “the app’s broken” calls. In this little adventure, I’ll show you how I built a local-first mobile app with React Native + RxDB. You’ll see: React Native + RxDB How two-way sync actually works without melting your server. How to handle “fun” situations like data conflicts. What not to forget when designing one of these beasts. How two-way sync actually works without melting your server. How to handle “fun” situations like data conflicts. What not to forget when designing one of these beasts. not Also — I’ll share one “last update wins” trick. Not always the right choice, but in our case… chef’s kiss. The Stack (a.k.a. My Chosen Weapons) The Stack (a.k.a. My Chosen Weapons) For this build, I rolled with: React Native — cross-platform magic: one codebase, iOS + Android. react-native-nitro-sqlite — because local storage without SQLite is like pizza without cheese. RxDB — offline-first, reactive DB that plays nice with sync. NestJS + TypeORM + PostgreSQL — the backend dream team. React Native — cross-platform magic: one codebase, iOS + Android. React Native react-native-nitro-sqlite — because local storage without SQLite is like pizza without cheese. react-native-nitro-sqlite RxDB — offline-first, reactive DB that plays nice with sync. RxDB NestJS + TypeORM + PostgreSQL — the backend dream team. NestJS + TypeORM + PostgreSQL The end result: app works offline, syncs later, and survives dodgy connections without having a meltdown. Step 1 — Local Storage via SQLite Step 1 — Local Storage via SQLite First up, I needed a local database that RxDB could happily abuse. SQLite is the obvious choice, but I wrapped it with some extra powers — validation and encryption — because data integrity and privacy matter (and also because my future self will thank me). //storage.ts import { getRxStorageSQLiteTrial, getSQLiteBasicsQuickSQLite, } from 'rxdb/plugins/storage-sqlite'; import { open } from 'react-native-nitro-sqlite'; import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv'; import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js'; const sqliteBasics = getSQLiteBasicsQuickSQLite(open); const storage = getRxStorageSQLiteTrial({ sqliteBasics }); const validatedStorage = wrappedValidateAjvStorage({ storage }); const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({ storage: validatedStorage, }); export { encryptedStorage }; //storage.ts import { getRxStorageSQLiteTrial, getSQLiteBasicsQuickSQLite, } from 'rxdb/plugins/storage-sqlite'; import { open } from 'react-native-nitro-sqlite'; import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv'; import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js'; const sqliteBasics = getSQLiteBasicsQuickSQLite(open); const storage = getRxStorageSQLiteTrial({ sqliteBasics }); const validatedStorage = wrappedValidateAjvStorage({ storage }); const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({ storage: validatedStorage, }); export { encryptedStorage }; Yes, that’s three layers of wrapping. Like an onion. Or an enterprise app with way too many middleware layers. Step 2 — Creating the Database Instance Step 2 — Creating the Database Instance Next, I built a RxDatabaseManager singleton. Because if you think having multiple instances of your DB is a good idea… you probably also enjoy merge conflicts in production. RxDatabaseManager Here’s the class in all its glory: //Instance.ts import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb'; import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; import { replicateRxCollection } from 'rxdb/plugins/replication'; import NetInfo from '@react-native-community/netinfo'; import { CheckPointType, MyDatabaseCollections, ReplicateCollectionDto, } from './types.ts'; import { encryptedStorage } from './storage.ts'; import { defaultConflictHandler } from './utills.ts'; import { usersApi, userSchema, UserType } from '../features/users'; import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema'; import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder'; import { RxDBUpdatePlugin } from 'rxdb/plugins/update'; // for support query.update method addRxPlugin(RxDBUpdatePlugin); // for support chained query methods addRxPlugin(RxDBQueryBuilderPlugin); // for enabling data migration addRxPlugin(RxDBMigrationSchemaPlugin); export class RxDatabaseManager { private static instance: RxDatabaseManager; private db: RxDatabase<MyDatabaseCollections> | null = null; private isOnline = false; private constructor() {} public static getInstance(): RxDatabaseManager { if (!RxDatabaseManager.instance) { RxDatabaseManager.instance = new RxDatabaseManager(); } return RxDatabaseManager.instance; } public async init(): Promise<RxDatabase<MyDatabaseCollections>> { if (this.db) return this.db; if (__DEV__) { // needs to be added in dev mode addRxPlugin(RxDBDevModePlugin); } this.db = await createRxDatabase<MyDatabaseCollections>({ name: 'myDb', storage: encryptedStorage, multiInstance: false, // No multi-instance support for React Native closeDuplicates: true, // Close duplicate database instances }); await this.db.addCollections({ users: { schema: userSchema, conflictHandler: defaultConflictHandler, migrationStrategies: { // 1: function (oldDoc: UserType) {}, }, }, }); this.setupConnectivityListener(); return this.db; } public getDb(): RxDatabase<MyDatabaseCollections> { if (!this.db) { throw new Error('Database not initialized. Call init() first.'); } return this.db; } private replicateCollection<T>(dto: ReplicateCollectionDto<T>) { const { collection, replicationId, api } = dto; const replicationState = replicateRxCollection<WithDeleted<T>, number>({ collection: collection, replicationIdentifier: replicationId, pull: { async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, batchSize: 20, }, push: { async handler(changeRows) { console.log('push'); const response = await api.push({ changeRows }); return response.data; }, }, }); replicationState.active$.subscribe(v => { console.log('Replication active$:', v); }); replicationState.canceled$.subscribe(v => { console.log('Replication canceled$:', v); }); replicationState.error$.subscribe(async error => { console.error('Replication error$:', error); }); } private async startReplication() { const db = this.getDb(); this.replicateCollection<UserType>({ collection: db.users, replicationId: '/users/sync', api: { push: usersApi.push, pull: usersApi.pull, }, }); } private setupConnectivityListener() { NetInfo.addEventListener(state => { const wasOffline = !this.isOnline; this.isOnline = Boolean(state.isConnected); if (this.isOnline && wasOffline) { this.onReconnected(); } }); } private async onReconnected() { this.startReplication(); } } //Instance.ts import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb'; import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; import { replicateRxCollection } from 'rxdb/plugins/replication'; import NetInfo from '@react-native-community/netinfo'; import { CheckPointType, MyDatabaseCollections, ReplicateCollectionDto, } from './types.ts'; import { encryptedStorage } from './storage.ts'; import { defaultConflictHandler } from './utills.ts'; import { usersApi, userSchema, UserType } from '../features/users'; import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema'; import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder'; import { RxDBUpdatePlugin } from 'rxdb/plugins/update'; // for support query.update method addRxPlugin(RxDBUpdatePlugin); // for support chained query methods addRxPlugin(RxDBQueryBuilderPlugin); // for enabling data migration addRxPlugin(RxDBMigrationSchemaPlugin); export class RxDatabaseManager { private static instance: RxDatabaseManager; private db: RxDatabase<MyDatabaseCollections> | null = null; private isOnline = false; private constructor() {} public static getInstance(): RxDatabaseManager { if (!RxDatabaseManager.instance) { RxDatabaseManager.instance = new RxDatabaseManager(); } return RxDatabaseManager.instance; } public async init(): Promise<RxDatabase<MyDatabaseCollections>> { if (this.db) return this.db; if (__DEV__) { // needs to be added in dev mode addRxPlugin(RxDBDevModePlugin); } this.db = await createRxDatabase<MyDatabaseCollections>({ name: 'myDb', storage: encryptedStorage, multiInstance: false, // No multi-instance support for React Native closeDuplicates: true, // Close duplicate database instances }); await this.db.addCollections({ users: { schema: userSchema, conflictHandler: defaultConflictHandler, migrationStrategies: { // 1: function (oldDoc: UserType) {}, }, }, }); this.setupConnectivityListener(); return this.db; } public getDb(): RxDatabase<MyDatabaseCollections> { if (!this.db) { throw new Error('Database not initialized. Call init() first.'); } return this.db; } private replicateCollection<T>(dto: ReplicateCollectionDto<T>) { const { collection, replicationId, api } = dto; const replicationState = replicateRxCollection<WithDeleted<T>, number>({ collection: collection, replicationIdentifier: replicationId, pull: { async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, batchSize: 20, }, push: { async handler(changeRows) { console.log('push'); const response = await api.push({ changeRows }); return response.data; }, }, }); replicationState.active$.subscribe(v => { console.log('Replication active$:', v); }); replicationState.canceled$.subscribe(v => { console.log('Replication canceled$:', v); }); replicationState.error$.subscribe(async error => { console.error('Replication error$:', error); }); } private async startReplication() { const db = this.getDb(); this.replicateCollection<UserType>({ collection: db.users, replicationId: '/users/sync', api: { push: usersApi.push, pull: usersApi.pull, }, }); } private setupConnectivityListener() { NetInfo.addEventListener(state => { const wasOffline = !this.isOnline; this.isOnline = Boolean(state.isConnected); if (this.isOnline && wasOffline) { this.onReconnected(); } }); } private async onReconnected() { this.startReplication(); } } This little beast: Sets up the DB. Watches for internet like a clingy ex. Syncs as soon as we’re back online. Sets up the DB. Watches for internet like a clingy ex. Syncs as soon as we’re back online. And yes, it logs everything. Future me will be grateful when debugging next time. Step 3 — Bootstrapping the DB When the App Starts Step 3 — Bootstrapping the DB When the App Starts When the app launches, I spin up my RxDatabaseManager instance. No magic here — just the good ol’ useEffect doing its thing. RxDatabaseManager useEffect If something explodes during init, I log it. Because pretending errors don’t exist is how you get haunted apps. //App.tsx useEffect(() => { const init = async () => { const dbManager = RxDatabaseManager.getInstance(); dbManager .init() .then(() => { setAppStatus('ready'); }) .catch((error) => { console.log('Error initializing database:', error); setAppStatus('error'); }); }; init(); }, []); //App.tsx useEffect(() => { const init = async () => { const dbManager = RxDatabaseManager.getInstance(); dbManager .init() .then(() => { setAppStatus('ready'); }) .catch((error) => { console.log('Error initializing database:', error); setAppStatus('error'); }); }; init(); }, []); Step 4 — Data Replication (a.k.a. Syncing Without Tears) Step 4 — Data Replication (a.k.a. Syncing Without Tears) When the app goes from “offline cave mode” back online, onReconnected() fires. That kicks off data sync between the local DB and the server via replicateRxCollection. onReconnected() replicateRxCollection Here’s the basic pull handler — RxDB sends a checkpoint (updatedAt, id) so the server knows where we left off. Because nobody wants to fetch the entireDB every time. updatedAt id entire //instance.ts async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, //instance.ts async handler(checkpointOrNull: unknown, batchSize: number) { const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, On the server, I query only the new/updated stuff since the last checkpoint. Because bandwidth is precious, and so is my patience. //users.query-repository.ts async pull(dto: PullUsersDto): Promise<UserViewDto[]> { const { id, updatedAt, batchSize } = dto; const users = await this.users .createQueryBuilder('user') .where('user.updatedAt > :updatedAt', { updatedAt }) .orWhere('user.updatedAt = :updatedAt AND user.id > :id', { updatedAt, id, }) .orderBy('user.updatedAt', 'ASC') .addOrderBy('user.id', 'ASC') .limit(batchSize) .getMany(); return users.map(UserViewDto.mapToView); } //users.query-repository.ts async pull(dto: PullUsersDto): Promise<UserViewDto[]> { const { id, updatedAt, batchSize } = dto; const users = await this.users .createQueryBuilder('user') .where('user.updatedAt > :updatedAt', { updatedAt }) .orWhere('user.updatedAt = :updatedAt AND user.id > :id', { updatedAt, id, }) .orderBy('user.updatedAt', 'ASC') .addOrderBy('user.id', 'ASC') .limit(batchSize) .getMany(); return users.map(UserViewDto.mapToView); } And then the server sends back both the docs and a shiny new checkpoint: //user.service.ts async pull(dto: PullUsersDto) { const users = await this.usersRepository.pull(dto); const newCheckpoint = users.length === 0 ? { id: dto.id, updatedAt: dto.updatedAt } : { id: users.at(-1)!.id, updatedAt: users.at(-1)!.updatedAt, }; return { documents: users, checkpoint: newCheckpoint, }; } //user.service.ts async pull(dto: PullUsersDto) { const users = await this.usersRepository.pull(dto); const newCheckpoint = users.length === 0 ? { id: dto.id, updatedAt: dto.updatedAt } : { id: users.at(-1)!.id, updatedAt: users.at(-1)!.updatedAt, }; return { documents: users, checkpoint: newCheckpoint, }; } Step 5 — Pushing Local Changes Back to the Server Step 5 — Pushing Local Changes Back to the Server RxDB also tracks what’s changed locally since the last sync and pushes it up. Think of it like Dropbox for your app’s data — without the random “conflicted copy” files (well… unless you handle conflicts badly). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, On the backend, I check each incoming change: If there’s no conflict, it goes straight in. If the server’s version is newer, I throw it into the conflicts pile. If there’s no conflict, it goes straight in. If the server’s version is newer, I throw it into the conflicts pile. conflicts //user.service.ts async push(dto: PushUsersDto) { const changeRows = dto.changeRows; const existingUsers = await this.usersRepository.findByIds( changeRows.map((changeRow) => changeRow.newDocumentState.id), ); const existingMap = new Map(existingUsers.map((user) => [user.id, user])); const toSave: UserViewDto[] = []; const conflicts: UserViewDto[] = []; for (const changeRow of changeRows) { const newDoc = changeRow.newDocumentState; const existing = existingMap.get(newDoc.id); const isConflict = existing && existing.updatedAt > newDoc?.updatedAt; if (isConflict) { conflicts.push(existing); } else { toSave.push(newDoc); } if (toSave.length > 0) { await this.usersRepository.save(toSave); } } return conflicts; } //user.service.ts async push(dto: PushUsersDto) { const changeRows = dto.changeRows; const existingUsers = await this.usersRepository.findByIds( changeRows.map((changeRow) => changeRow.newDocumentState.id), ); const existingMap = new Map(existingUsers.map((user) => [user.id, user])); const toSave: UserViewDto[] = []; const conflicts: UserViewDto[] = []; for (const changeRow of changeRows) { const newDoc = changeRow.newDocumentState; const existing = existingMap.get(newDoc.id); const isConflict = existing && existing.updatedAt > newDoc?.updatedAt; if (isConflict) { conflicts.push(existing); } else { toSave.push(newDoc); } if (toSave.length > 0) { await this.usersRepository.save(toSave); } } return conflicts; } Step 6 — Conflict Resolution (The “Last Update Wins” Gambit) Step 6 — Conflict Resolution (The “Last Update Wins” Gambit) Here’s where people usually overcomplicate things. Yes, you could build a NASA-grade merge strategy. Or… you could go with “last update wins” and call it a day. could It’s not always the right move, but in our case — simple, fast, good enough. //utills.ts export const defaultConflictHandler: RxConflictHandler<{ updatedAt: number; }> = { isEqual(a, b) { return a.updatedAt === b.updatedAt; }, resolve({ assumedMasterState, realMasterState, newDocumentState }) { return Promise.resolve(realMasterState); }, }; //utills.ts export const defaultConflictHandler: RxConflictHandler<{ updatedAt: number; }> = { isEqual(a, b) { return a.updatedAt === b.updatedAt; }, resolve({ assumedMasterState, realMasterState, newDocumentState }) { return Promise.resolve(realMasterState); }, }; Step 7 — Keeping the UI in Sync Step 7 — Keeping the UI in Sync After init, the app status flips to Ready, and we just… use it. No weird manual refresh buttons, no “tap to reload” nonsense. Ready //UsersScreen.tsx export const UsersScreen = () => { const users = useUsersSelector({ sort: [{ updatedAt: 'desc' }], }); const { createUser, deleteUser, updateUser } = useUsersService(); return ( <View style={styles.container}> {users.map(user => ( <Text key={user.id}>{user.name}</Text> ))} <Button title={'Create new user'} onPress={createUser} /> <Button disabled={users.length === 0} title={'Update user'} onPress={() => updateUser(users[0].id)} /> <Button disabled={users.length === 0} title={'Delete user'} onPress={() => deleteUser(users[0].id)} /> </View> ); }; //UsersScreen.tsx export const UsersScreen = () => { const users = useUsersSelector({ sort: [{ updatedAt: 'desc' }], }); const { createUser, deleteUser, updateUser } = useUsersService(); return ( <View style={styles.container}> {users.map(user => ( <Text key={user.id}>{user.name}</Text> ))} <Button title={'Create new user'} onPress={createUser} /> <Button disabled={users.length === 0} title={'Update user'} onPress={() => updateUser(users[0].id)} /> <Button disabled={users.length === 0} title={'Delete user'} onPress={() => deleteUser(users[0].id)} /> </View> ); }; useUsersSelectorsubscribes to changes in the DB, so the UI updates itself. This is one of those “works like magic” moments that you shouldn’t think too hard about — just enjoy it. useUsersSelector //user.selector.tsx export const useUsersSelector = (query?: MangoQuery<UserType>) => { const userModel = RxDatabaseManager.getInstance().getDb().users; const [users, setUsers] = useState<UserType[]>([]); useEffect(() => { const subscription = userModel.find(query).$.subscribe(result => { setUsers(result); }); return () => subscription.unsubscribe(); }, [userModel, query]); return users; }; //user.selector.tsx export const useUsersSelector = (query?: MangoQuery<UserType>) => { const userModel = RxDatabaseManager.getInstance().getDb().users; const [users, setUsers] = useState<UserType[]>([]); useEffect(() => { const subscription = userModel.find(query).$.subscribe(result => { setUsers(result); }); return () => subscription.unsubscribe(); }, [userModel, query]); return users; }; Final Thoughts Final Thoughts We’ve basically built an app that: Works offline. Syncs automatically. Doesn’t care if your Wi-Fi is having an existential crisis. Works offline. Syncs automatically. Doesn’t care if your Wi-Fi is having an existential crisis. We used this same setup for Sizl’s dark kitchen riders in Chicago, where connectivity is… let’s just say “urban adventure mode.” Riders can now finish orders, take proof-of-delivery photos, and mark deliveries complete without internet. The app syncs later. Sizl’s dark kitchen riders in Chicago Sure, real-world cases can get gnarlier — multiple devices updating the same record, related data deletions, massive datasets. But the pattern holds. You just need to extend it with smarter conflict resolution and a more flexible architecture. Would I recommend going local-first? Absolutely — unless you enjoy user rage tickets that start with “I was offline and…” Click the link in my bio for more info on this one & other projects! Click the link in my bio for more info on this one & other projects! To check out our open source admin panel on React see our Github. To check out our open source admin panel on React see our Github. Github