Offline-First Persistence in React Native with Realm: Schema, Migrations, and Safe Writes
A practical guide to local-only Realm in React Native: schema definition with primary keys, atomic realm.write() transactions, live queries, and safe schema migrations for offline-first apps.
27 Sept 2025, 18:42 UTC

The decision you're actually making
If your React Native app must keep working without a network, you need local persistence that survives crashes and app upgrades. The practical choice here is a local-only Realm database: no backend dependency, no sync conflict resolution, and no SQL layer to maintain. Note that MongoDB has deprecated Atlas Device Sync, so this guide deliberately covers the local-only path — treat any sync-based design as requiring current verification before you commit to it.
The takeaway: define a schema with a primary key, wrap every mutation in realm.write(), and evolve the schema with an explicit schemaVersion plus a migration callback. Get those three right and offline-first persistence is largely solved.
Prerequisites
- A React Native project with the Realm JavaScript SDK installed (
npm install realmplus the usual native rebuild). API names below match the modernrealmnpm package; confirm against your installed version's changelog, since names vary across major releases. - A decision on your data model — Realm is object-oriented, so you define classes or schema objects, not tables.
Step 1: Define the schema and open the database
Run this in your app's data layer (for example, a module imported by your screens). No special permissions are needed; Realm stores a file in the app's sandboxed documents directory.
import Realm from 'realm';
class Task extends Realm.Object {
static schema = {
name: 'Task',
primaryKey: 'id',
properties: {
id: 'string',
title: 'string',
done: { type: 'bool', default: false },
createdAt: 'date',
},
};
}
const realm = await Realm.open({
schema: [Task],
schemaVersion: 1,
});The primary key matters: it gives you idempotent writes (re-inserting the same id can update rather than duplicate) and stable identity for offline-created records. Use UUIDs or another collision-free generator, since there's no server to assign IDs.
Step 2: Write only inside transactions
Every create, update, or delete must happen inside realm.write(). Writes are atomic: if the app crashes or an exception is thrown mid-transaction, the database keeps its last committed state. That atomicity is your core recovery mechanism — there is no half-written row to clean up.
function addTask(title) {
realm.write(() => {
realm.create('Task', {
id: new Realm.BSON.UUID().toHexString(),
title,
createdAt: new Date(),
});
});
}Check: after reopening the app, realm.objects('Task') should still return the inserted records. Persistence across restarts is the baseline verification for offline-first.
Step 3: Query live results and subscribe to changes
Realm results are live and auto-updating. Instead of re-querying after each write, attach a change listener and let the UI react:
const openTasks = realm.objects('Task').filtered('done == false').sorted('createdAt');
openTasks.addListener((collection, changes) => {
// trigger a state update / re-render here
});Queries execute lazily against live objects, not detached copies. Two consequences: your UI always sees committed data, and objects become invalid if accessed after the Realm is closed or passed carelessly across threads. If you must hand data to a context that outlives the Realm (logging, background workers), copy or freeze it first — for example, spread the fields into a plain object.
Step 4: Migrate the schema safely
When you change the model — say, renaming title to summary — bump schemaVersion and supply a migration. Opening an existing file with a higher version and no migration throws, which means a broken upgrade path for every existing user.
const realm = await Realm.open({
schema: [Task],
schemaVersion: 2,
migration: (oldRealm, newRealm) => {
if (oldRealm.schemaVersion < 2) {
const oldTasks = oldRealm.objects('Task');
const newTasks = newRealm.objects('Task');
for (let i = 0; i < oldTasks.length; i++) {
newTasks[i].summary = oldTasks[i].title;
}
}
},
});Migrations run on every user's device at upgrade, and a faulty one can corrupt the upgrade path permanently. Test against real realm files produced by the previous app version — keep a copy of an old file in your test fixtures and open it with the new schema before shipping.
Verifying the failure behavior
Before trusting this in production, run three checks in a debug build:
- Persistence: insert records, force-close the app, relaunch, confirm
objects('Task')returns them. - Atomicity: throw an exception deliberately inside a
realm.write()block, then confirm the database still holds its pre-transaction state. - Migration: install the old app version, create data, upgrade in place, and confirm old records are preserved and transformed.
Limitations to plan around
- Local-only means no cross-device data. If you later need sync, that is a separate architectural project, and the deprecated Device Sync path requires current verification of what MongoDB actually supports now.
- Live objects tie your data to the Realm's lifecycle; closing the instance invalidates everything queried from it.
- Exact property types and API names differ across SDK versions — check the release notes for the version you have installed rather than assuming examples from other versions apply.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.