Mobile apps lose connectivity all the time: in lifts, on trains, in basements. Firebase’s Firestore and Realtime Database both work offline, caching data on the device, queueing writes and syncing them when the connection returns. This guide shows how that works in a React Native app with React Native Firebase, what you must configure (one of the two databases is not persistent by default), and how to build a UI that tells users what has and has not synced.
What I tested: the React Native code type-checks against React Native Firebase 26.4. To observe offline behavior, I ran tests against the Firebase Firestore and Realtime Database emulators using the Firebase JS SDK 13.0, which shares the same modular API: offline writes were visible locally and flagged as pending, other devices did not see them until reconnecting, they synced afterwards, two offline edits resolved as “last write wins”, and a Realtime Database write queued offline was delivered after reconnecting while an onDisconnect handler marked the user offline. I did not run the app on a device.
The 2024 version of this post enabled offline support with a persistence: true option passed to initializeApp. That option does not exist; persistence is configured per database, as below.
Which Database Is Persistent by Default?
| Cloud Firestore | Realtime Database | |
|---|---|---|
| On-disk cache on Android and iOS | On by default | Off by default; call setPersistenceEnabled(true) |
| Default cache size | 100 MB on the native SDKs (configurable) | 10 MB of previously synced data |
| Offline writes | Queued and applied locally immediately | Queued; survive restarts only with persistence enabled |
| Transactions offline | Fail | Not persisted across app restarts |
| Presence | Not built in | onDisconnect runs on the server when the client drops |
Sources: Firebase’s Firestore offline guide, the Realtime Database offline capabilities page and the React Native Firebase Firestore docs, which state that persistence is enabled by default and that “Transactions will fail when the client is offline”.
A note for Expo Go users: the Firebase JavaScript SDK on React Native only has a memory cache (its persistent cache relies on the browser’s IndexedDB), so offline data does not survive an app restart. For durable offline support, use React Native Firebase, which wraps the native Android and iOS SDKs and needs a development build.
Step 1: Install React Native Firebase
npm install @react-native-firebase/app @react-native-firebase/firestore @react-native-firebase/database @react-native-community/netinfo
cd ios && pod installAdd google-services.json (Android) and GoogleService-Info.plist (iOS) from the Firebase console as described in the React Native Firebase setup. In Expo, use the config plugins and a development build.
Step 2: Configure Persistence
import { getApp } from '@react-native-firebase/app';
import { initializeFirestore } from '@react-native-firebase/firestore';
import { getDatabase, setPersistenceEnabled, setPersistenceCacheSizeBytes } from '@react-native-firebase/database';
// React Native Firebase reads google-services.json / GoogleService-Info.plist, so no config object here.
// Firestore: the native SDKs keep an on-disk cache by default. Set it explicitly and size it.
export const db = initializeFirestore(getApp(), {
persistence: true,
cacheSizeBytes: 50 * 1024 * 1024, // 50 MB; the least recently used data is evicted beyond this
});
// Realtime Database: disk persistence is OFF by default. Enable it before any other
// database call, otherwise queued writes and cached data are lost when the app restarts.
export const rtdb = getDatabase();
export async function enableRealtimeDatabasePersistence() {
await setPersistenceEnabled(rtdb, true);
await setPersistenceCacheSizeBytes(rtdb, 20 * 1024 * 1024);
}Call initializeFirestore before any other Firestore call; React Native Firebase notes that otherwise the settings only take effect on the next launch. Call enableRealtimeDatabasePersistence() once at startup, before you read or write the Realtime Database. For apps with sensitive data you may prefer the opposite: persistence: false keeps Firestore data off the disk.
Step 3: Write Offline-Friendly Code
The key habit: do not await writes in the UI. A Firestore write is applied to the local cache immediately, but its promise only resolves when the server acknowledges it, which never happens while offline. Fire the write, let listeners update the UI, and handle rejection separately:
import {
addDoc, collection, onSnapshot, orderBy, query, serverTimestamp, type Firestore,
} from '@react-native-firebase/firestore';
export type Note = { id: string; text: string; pending: boolean };
// Writes go to the local cache immediately and sync when the device is back online.
// Do not await them in the UI: the promise only resolves once the server confirms.
export function addNote(db: Firestore, uid: string, text: string) {
addDoc(collection(db, 'users', uid, 'notes'), { text, createdAt: serverTimestamp() }).catch(err =>
console.warn('Write rejected by the server (for example by Security Rules)', err),
);
}
// includeMetadataChanges lets the UI show which notes are still waiting to sync.
export function watchNotes(db: Firestore, uid: string, onChange: (notes: Note[], fromCache: boolean) => void) {
const q = query(collection(db, 'users', uid, 'notes'), orderBy('createdAt', 'desc'));
return onSnapshot(q, { includeMetadataChanges: true }, snap =>
onChange(
snap.docs.map(d => ({ id: d.id, text: d.get('text') as string, pending: d.metadata.hasPendingWrites })),
snap.metadata.fromCache,
),
);
}With includeMetadataChanges, the listener fires again when a pending write is confirmed, so each note can show a “waiting to sync” icon until hasPendingWrites turns false. fromCache tells you whether the whole result came from the cache and might be stale.
Step 4: Show Connection Status
Firebase reconnects on its own; a network indicator is only for the user. NetInfo gives you the device’s connectivity:
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
// Network status for the UI. Firebase handles reconnection itself; this is only for the banner.
export function useOnline() {
const [online, setOnline] = useState<boolean | null>(null);
useEffect(() => NetInfo.addEventListener(s => setOnline(Boolean(s.isConnected && s.isInternetReachable !== false))), []);
return online;
}A banner such as “You’re offline. Changes will sync when you reconnect” combined with per-item pending markers is usually enough.
Step 5: Presence and Queued Writes in Realtime Database
Realtime Database’s onDisconnect registers a write that the server performs when the client’s connection drops, which makes online/offline presence reliable even if the app crashes:
import { onDisconnect, ref, serverTimestamp, set } from '@react-native-firebase/database';
import { rtdb } from './firebase';
// Realtime Database presence: the server marks the user offline when the connection drops.
export async function trackPresence(uid: string) {
const status = ref(rtdb, `status/${uid}`);
await onDisconnect(status).set({ state: 'offline', lastChanged: serverTimestamp() });
await set(status, { state: 'online', lastChanged: serverTimestamp() });
}Use keepSynced(ref, true) on data the app must always have offline, such as the user’s settings, so it stays cached even when no screen is listening to it.
Handling Conflicts
When two devices edit the same document offline, Firestore applies the writes in the order they reach the server, so the last one to sync wins. My emulator test showed exactly that: device A and device B both edited a document offline, A reconnected first, and B’s later sync overwrote A’s change. Ways to avoid losing data:
- Update individual fields (
updateDocwith only the changed fields) instead of overwriting whole documents. - Use
arrayUnion,arrayRemoveandincrement, which merge instead of overwrite. - Model data as separate documents (one per comment or item) rather than one large shared document.
- For values where order matters, such as stock levels, use a transaction or a Cloud Function, and accept that it needs a connection.
Testing Offline Behavior
You can test the sync logic without a phone by running the Firebase emulators and toggling the network in code. These are the tests I ran, using the JS SDK’s disableNetwork and enableNetwork for Firestore and goOffline and goOnline for the Realtime Database:
// Offline behaviour of Firestore and Realtime Database, observed against the emulators.
// React Native Firebase uses the same modular API on top of the native SDKs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { deleteApp, initializeApp } from 'firebase/app';
import {
connectFirestoreEmulator, initializeFirestore, memoryLocalCache, collection, doc, setDoc, getDoc, getDocsFromCache,
getDocFromServer, disableNetwork, enableNetwork, waitForPendingWrites, onSnapshot,
} from 'firebase/firestore';
import { connectDatabaseEmulator, getDatabase, ref, set, get, goOffline, goOnline, onDisconnect, onValue } from 'firebase/database';
function client(name) {
const app = initializeApp({ projectId: 'demo-offline', apiKey: 'x', databaseURL: 'http://127.0.0.1:9000?ns=demo-offline' }, name);
const db = initializeFirestore(app, { localCache: memoryLocalCache() });
connectFirestoreEmulator(db, '127.0.0.1', 8080);
const rtdb = getDatabase(app);
connectDatabaseEmulator(rtdb, '127.0.0.1', 9000);
return { app, db, rtdb };
}
test('Firestore: offline writes are visible locally, marked pending, and sync on reconnect', async () => {
const a = client('a');
const notes = collection(a.db, 'users/u1/notes');
await setDoc(doc(notes, 'n0'), { text: 'written online' });
await disableNetwork(a.db); // simulate losing connectivity
const pendingSnaps = [];
const stop = onSnapshot(notes, { includeMetadataChanges: true }, s =>
pendingSnaps.push({ size: s.size, pending: s.metadata.hasPendingWrites, fromCache: s.metadata.fromCache }));
const write = setDoc(doc(notes, 'n1'), { text: 'written offline' }); // not awaited: resolves only after sync
const local = await getDocsFromCache(notes);
assert.equal(local.size, 2); // the offline write is readable immediately
const sawPending = () => pendingSnaps.some(s => s.size === 2 && s.pending && s.fromCache);
for (let i = 0; i < 20 && !sawPending(); i++) await new Promise(r => setTimeout(r, 50)); // listeners fire asynchronously
assert.ok(sawPending());
const b = client('b'); // another device does not see it yet
assert.equal((await getDocFromServer(doc(b.db, 'users/u1/notes/n1'))).exists(), false);
await enableNetwork(a.db); // back online
await write;
await waitForPendingWrites(a.db);
assert.equal((await getDocFromServer(doc(b.db, 'users/u1/notes/n1'))).data().text, 'written offline');
// The "synced" metadata snapshot can arrive just after waitForPendingWrites resolves.
for (let i = 0; i < 20 && pendingSnaps.at(-1).pending; i++) await new Promise(r => setTimeout(r, 50));
assert.equal(pendingSnaps.at(-1).pending, false);
stop();
await deleteApp(a.app); await deleteApp(b.app);
});
test('Firestore: last write wins when two devices edit offline', async () => {
const a = client('c'), b = client('d');
const ref1 = doc(a.db, 'docs/shared');
await setDoc(ref1, { title: 'original' });
await disableNetwork(a.db); await disableNetwork(b.db);
const wa = setDoc(doc(a.db, 'docs/shared'), { title: 'from A' });
const wb = setDoc(doc(b.db, 'docs/shared'), { title: 'from B' });
await enableNetwork(a.db); await wa; // A syncs first
await enableNetwork(b.db); await wb; // B syncs second and overwrites
assert.equal((await getDocFromServer(ref1)).data().title, 'from B');
await deleteApp(a.app); await deleteApp(b.app);
});
test('Realtime Database: queued offline writes and onDisconnect presence', async () => {
const a = client('f'), b = client('g');
const status = ref(a.rtdb, 'status/u1');
await onDisconnect(status).set({ state: 'offline' });
await set(status, { state: 'online' });
assert.equal((await get(ref(b.rtdb, 'status/u1'))).val().state, 'online');
goOffline(a.rtdb); // connection drops: server runs onDisconnect
const sawOffline = await new Promise(resolve => {
const t = setTimeout(() => resolve(false), 5000);
onValue(ref(b.rtdb, 'status/u1'), s => { if (s.val()?.state === 'offline') { clearTimeout(t); resolve(true); } });
});
assert.equal(sawOffline, true);
const queued = set(ref(a.rtdb, 'drafts/u1'), { text: 'typed offline' }); // queued while offline
goOnline(a.rtdb);
await queued;
assert.equal((await get(ref(b.rtdb, 'drafts/u1'))).val().text, 'typed offline');
await deleteApp(a.app); await deleteApp(b.app);
});$ npx firebase emulators:exec --only firestore,database "node --test --test-concurrency=1 offline.test.mjs"
✔ Firestore: offline writes are visible locally, marked pending, and sync on reconnect
✔ Firestore: last write wins when two devices edit offline
✔ Realtime Database: queued offline writes and onDisconnect presence
ℹ pass 3Two things I learned writing them. Snapshot listeners fire asynchronously, so the tests wait for the “pending” and “synced” snapshots instead of checking immediately. And disableNetwork is not a perfect stand-in for losing signal: a transaction I started while it was active still succeeded against the emulator, so I removed that test. Test transactions and real-world offline behavior on a device in airplane mode.
Checklist
- Firestore: persistence is on by default with React Native Firebase; size the cache with
cacheSizeBytes. - Realtime Database: call
setPersistenceEnabled(true)before first use. - Do not await writes in the UI; show pending state from snapshot metadata.
- Avoid transactions for actions users take offline, or tell users they need a connection.
- Design data to merge: field updates, increments and array operations.
- Test in airplane mode on real devices, including app restarts while offline.
Conclusion
Offline support in a React Native Firebase app is mostly built in: Firestore caches to disk by default, the Realtime Database does once you enable it, and both queue writes until the connection returns. Your job is to enable the right settings, keep the UI responsive by not waiting on writes, show users what is pending, and design data so conflicting offline edits merge cleanly. For other on-device storage options, see local storage in Expo.
FAQs
Does Firestore work offline in React Native?
Yes. With React Native Firebase, the native Firestore SDKs cache data on disk by default, serve queries from the cache when offline and queue writes until the device reconnects.
Is Realtime Database persistence enabled by default?
No. Call setPersistenceEnabled(true) before using the database, otherwise cached data and queued writes are lost when the app restarts.
Why does my Firestore write promise never resolve offline?
The promise resolves when the server confirms the write. Offline, the write is already applied locally, so update the UI from listeners and do not block on the promise.
Do Firestore transactions work offline?
No. Transactions need the server and fail when the client is offline. Use regular writes, increments or a Cloud Function for actions users take without a connection.
Does the Firebase JS SDK persist data offline in Expo Go?
Only in memory. Its persistent cache depends on IndexedDB, which React Native does not have. Use React Native Firebase in a development build for on-disk offline persistence.
What happens when two users edit the same document offline?
The write that reaches the server last wins. Update individual fields and use operations like increment and arrayUnion to reduce lost updates.








