Introduction
Mobile users expect their apps to work seamlessly whether they're on a flight, in a subway tunnel, or sitting in a coffee shop with spotty WiFi. MongoDB for mobile and offline apps: a step-by-step guide shows you how to build resilient applications that sync data to devices and keep users productive even when connectivity drops. We'll walk through the patterns and tooling your team needs to implement robust offline-first architecture.
Store data locally on device
The foundation of any offline-capable mobile app is a local data store. Rather than making every action depend on a round-trip to the server, you replicate a subset of your MongoDB data onto the device itself. This local replica becomes your source of truth for reads and writes until connectivity returns.
For many teams, MongoDB Realm (now part of the MongoDB ecosystem) provides an embedded database designed exactly for this use case. Realm syncs with a MongoDB Atlas backend and handles conflict resolution out of the box. If you're building on React Native or Flutter, Realm's SDKs integrate cleanly.
Here's how you might structure a simple notes app that works offline:
// React Native example with Realm
import Realm from 'realm';
const NoteSchema = {
name: 'Note',
primaryKey: '_id',
properties: {
_id: 'objectId',
title: 'string',
content: 'string',
userId: 'string',
createdAt: 'date',
updatedAt: 'date',
synced: { type: 'bool', default: false }
}
};
const NoteRealm = new Realm({ schema: [NoteSchema] });
// Create a note locally—this works immediately, offline or not
export const createNote = (userId, title, content) => {
let newNote;
NoteRealm.write(() => {
newNote = NoteRealm.create('Note', {
_id: new Realm.BSON.ObjectId(),
title,
content,
userId,
createdAt: new Date(),
updatedAt: new Date(),
synced: false
});
});
return newNote;
};
// Read notes—always available, no network call needed
export const getNotesByUser = (userId) => {
return NoteRealm.objects('Note').filtered(`userId = "${userId}"`);
};The key insight here is the synced flag. Every local mutation is marked as unsynced until the backend acknowledges it. This gives you a clear queue of work that needs to replicate.
If you're not using Realm, consider SQLite (via libraries like React Native SQLite Storage) or WatermelonDB for React Native. Each trade-offs complexity versus flexibility. SQLite is battle-tested but requires you to manage schema and sync yourself; WatermelonDB adds an abstraction layer designed for reactive apps.
Sync changes when online
Once the device detects it's back online, you need to push local changes and pull remote changes. This sync process must be reliable, efficient, and transparent to the user.
A common pattern is to queue mutations locally and replay them to the server when connectivity returns. However, naive approaches can create thundering herds if many users reconnect at once, or lose data if the app crashes mid-sync.
Here's a more robust approach using a dedicated sync queue:
// Sync queue manager
import axios from 'axios';
import NetInfo from '@react-native-community/netinfo';
class SyncQueue {
constructor(realm) {
this.realm = realm;
this.syncing = false;
this.checkConnectivity();
}
checkConnectivity = async () => {
const subscription = NetInfo.addEventListener(state => {
if (state.isConnected && !this.syncing) {
this.sync();
}
});
return subscription;
};
// Mark a note for syncing
markForSync = (noteId) => {
this.realm.write(() => {
const note = this.realm.objectForPrimaryKey('Note', noteId);
if (note) {
note.synced = false;
}
});
};
// Batch sync unsynced notes
sync = async () => {
if (this.syncing) return;
this.syncing = true;
try {
const unsyncedNotes = this.realm.objects('Note').filtered('synced = false');
if (unsyncedNotes.length === 0) {
this.syncing = false;
return;
}
// Send in batches to avoid overwhelming the server
const batch = Array.from(unsyncedNotes).slice(0, 50);
const payload = batch.map(note => ({
_id: note._id.toString(),
title: note.title,
content: note.content,
userId: note.userId,
updatedAt: note.updatedAt
}));
const response = await axios.post(
'https://api.myapp.com/notes/sync',
{ notes: payload },
{ timeout: 15000 }
);
// Mark synced only if the server confirmed
this.realm.write(() => {
batch.forEach(note => {
const serverConfirmed = response.data.synced.some(
id => id === note._id.toString()
);
if (serverConfirmed) {
note.synced = true;
}
});
});
// Recursively sync remaining items
if (unsyncedNotes.length > 50) {
await this.sync();
}
} catch (error) {
console.error('Sync failed:', error);
// Don't mark as synced; retry on next connectivity check
} finally {
this.syncing = false;
}
};
}
export default SyncQueue;The batching approach here is important: pushing 5,000 changes in a single HTTP request will timeout and block the user. Instead, we send smaller batches and recursively process the queue. Server timeout handling ensures we don't lose data if the network flakes mid-sync.
For pulling remote changes, use a timestamp-based polling strategy:
// Pull remote changes
sync = async () => {
// ... push logic ...
// Now pull remote changes
const lastSyncTime = await this.getLastSyncTimestamp();
const remoteChanges = await axios.get(
`https://api.myapp.com/notes/changes?since=${lastSyncTime}&userId=${userId}`
);
this.realm.write(() => {
remoteChanges.data.forEach(remoteNote => {
const localNote = this.realm.objectForPrimaryKey('Note', remoteNote._id);
if (!localNote) {
// New note from remote
this.realm.create('Note', remoteNote, 'modified');
} else if (remoteNote.updatedAt > localNote.updatedAt) {
// Remote is newer; update
Object.assign(localNote, remoteNote);
}
// If local is newer, keep local (or handle via conflict resolution)
});
});
await this.setLastSyncTimestamp(new Date());
};Resolve conflicts with timestamps or rules
In offline-first systems, conflicts are inevitable. The same note might be edited on two devices while offline, then both changes arrive at the server. Last-write-wins (LWW) using timestamps is simple but loses data. You need explicit conflict resolution rules.
The most practical approach for many mobile apps is field-level merging with timestamps. Instead of taking the entire remote or local document, you merge field by field, keeping whichever version is newer:
// Conflict resolution with timestamp comparison
const resolveConflict = (local, remote) => {
const merged = { ...local };
// For each field in the remote version, compare timestamps
['title', 'content', 'tags'].forEach(field => {
// If remote has a newer updatedAt, take remote's value
if (remote.fieldUpdatedAt?.[field] > local.fieldUpdatedAt?.[field]) {
merged[field] = remote[field];
}
});
merged.updatedAt = new Date();
return merged;
};
// Usage in sync
this.realm.write(() => {
remoteChanges.data.forEach(remoteNote => {
const localNote = this.realm.objectForPrimaryKey('Note', remoteNote._id);
if (localNote && localNote.updatedAt !== remoteNote.updatedAt) {
// Conflict detected
const resolved = resolveConflict(localNote, remoteNote);
Object.assign(localNote, resolved);
} else if (!localNote) {
this.realm.create('Note', remoteNote, 'modified');
}
});
});For more complex scenarios—like collaborative editing—consider storing a vector clock or operational transform. But for most mobile apps, field-level LWW with user-configurable rules (e.g., "always keep local changes to starred notes") strikes the right balance.
Document your conflict resolution policy explicitly. Users need to understand why sometimes a remote change overwrites their local work.
Consider dedicated sync services
As your offline-first requirements grow, implementing sync from scratch becomes a source of bugs. Dedicated sync services abstract away the hard parts: deduplication, idempotency, ordering, and observability.
MongoDB Realm Sync is the native choice if you're already on MongoDB. It handles conflict resolution, schema validation, and permissions seamlessly. Your mobile code simply reads and writes to a local Realm, and changes automatically replicate bidirectionally.
For teams not using Realm, consider:
- RxDB: Open-source, works in browsers and React Native, with pluggable sync adapters. It's lightweight and gives you control over conflict resolution logic.
- Couchbase Lite: Enterprise-grade embedded database with built-in sync to Couchbase Server or custom backends.
- Amazon AppSync: Managed GraphQL service with offline data sync, conflict resolution, and multi-user scenarios.
- Firebase Realtime Database or Firestore: If you're on Google Cloud, these offer offline persistence and automatic sync out of the box.
If you build custom sync, invest in these observability tools:
- Log every sync attempt with timestamp, device ID, user ID, and result.
- Track sync latency from "offline detected" to "all changes synced."
- Count conflicts resolved and data loss incidents.
- Alert on sync queues that grow unbounded (sign of a stuck client).
For a travel app syncing user bookings, you might use Realm Sync with a custom conflict resolver that always prefers the most recent booking modification time, and logs every conflict to Datadog for auditing.
Common mistakes
- Assuming network availability: Don't rely on a single connectivity check. Devices can be in "connected but no internet" states. Always handle sync failures gracefully and retry indefinitely.
- Syncing the entire database: On a device with limited storage, replicating all MongoDB data is infeasible. Use query-based replication—only sync the notes the user owns or recently accessed.
- Losing unsynced changes on app crash: If you don't persist the sync queue to the local database, a crash wipes out pending mutations. Always write to the database before attempting to sync.
- Ignoring clock skew: Devices clocks drift. Two phones can have updatedAt timestamps seconds apart even if they changed the note at the same wall-clock time. Consider server-assigned timestamps for critical fields.
- Not testing offline thoroughly: Use network simulators (Android Emulator's throttling, Xcode's network link conditioner) to reproduce poor connectivity. Don't just unplug WiFi and test once.
- Overlapping sync requests: If the sync queue triggers while a sync is already in flight, you risk duplicate pushes or stale reads. Use a mutex or flag like `this.syncing` to serialize.
Key takeaways
- Store a local copy of MongoDB data on the device using Realm, SQLite, or WatermelonDB so the app remains functional offline.
- Queue mutations locally with a synced flag and push them when connectivity returns, using batching and idempotency tokens to ensure reliability.
- Pull remote changes periodically using timestamp-based queries, merging field-by-field to resolve conflicts without losing data.
- Implement explicit conflict resolution rules and document them; last-write-wins is simple but often loses data unexpectedly.
- Use a dedicated sync service like MongoDB Realm Sync if complexity justifies it, otherwise build your own but invest heavily in observability and testing.
- Test offline scenarios aggressively with network simulators; real-world connectivity is far more chaotic than toggling WiFi off.






Comments
Loading comments…