Scientyfic World

Firebase Offline Support in React Native: Firestore and Realtime Database

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...

Share:

Get an AI summary of this article

Firebase offline support blog banner

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 FirestoreRealtime Database
On-disk cache on Android and iOSOn by defaultOff by default; call setPersistenceEnabled(true)
Default cache size100 MB on the native SDKs (configurable)10 MB of previously synced data
Offline writesQueued and applied locally immediatelyQueued; survive restarts only with persistence enabled
Transactions offlineFailNot persisted across app restarts
PresenceNot built inonDisconnect 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 install

Add 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 (updateDoc with only the changed fields) instead of overwriting whole documents.
  • Use arrayUnion, arrayRemove and increment, 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 3

Two 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

  1. Firestore: persistence is on by default with React Native Firebase; size the cache with cacheSizeBytes.
  2. Realtime Database: call setPersistenceEnabled(true) before first use.
  3. Do not await writes in the UI; show pending state from snapshot metadata.
  4. Avoid transactions for actions users take offline, or tell users they need a connection.
  5. Design data to merge: field updates, increments and array operations.
  6. 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts