Offline Persistence
Firestore and the Realtime Database both ship robust offline support — writes queue locally, reads serve from cache, and everything reconciles when the network returns. Use the right configuration and you get a snappy app that survives subway tunnels and airplane mode.
Persistence, queues, listeners, conflicts
EXAMPLE
// 1) Enable Firestore offline persistence (Web)
import { initializeApp } from 'firebase/app';
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager,
CACHE_SIZE_UNLIMITED,
} from 'firebase/firestore';
const app = initializeApp(firebaseConfig);
const db = initializeFirestore(app, {
localCache: persistentLocalCache({
cacheSizeBytes: CACHE_SIZE_UNLIMITED,
tabManager: persistentMultipleTabManager(),
}),
});
// 2) iOS / Android (default = ON; tune the cache size)
// iOS Swift
import FirebaseFirestore
let settings = FirestoreSettings()
settings.cacheSettings = PersistentCacheSettings(sizeBytes: NSNumber(value: 200 * 1024 * 1024))
Firestore.firestore().settings = settings
// Android Kotlin
val settings = firestoreSettings {
setLocalCacheSettings(persistentCacheSettings { setSizeBytes(200 * 1024 * 1024) })
}
Firebase.firestore.firestoreSettings = settings
// 3) Listeners served from cache when offline
import { collection, query, where, onSnapshot } from 'firebase/firestore';
const q = query(
collection(db, 'todos'),
where('userId', '==', uid),
);
onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
snap.docs.forEach((d) => {
const data = d.data();
const fromCache = snap.metadata.fromCache;
const hasPending = d.metadata.hasPendingWrites;
console.log(data.title, { fromCache, hasPending });
});
});
// metadata.fromCache — true when the listener fired from cache
// metadata.hasPendingWrites — true when this doc has a queued local write
// Show a subtle 'syncing…' UI badge based on these.
// 4) Writes work offline — they queue
import { doc, addDoc, updateDoc, deleteDoc, setDoc, serverTimestamp } from 'firebase/firestore';
await addDoc(collection(db, 'todos'), {
title: 'buy milk',
done: false,
createdAt: serverTimestamp(), // resolved when the write reaches the server
});
// Returns IMMEDIATELY in offline mode. The doc gets a local id; once online, the server confirms.
await updateDoc(doc(db, 'todos', id), { done: true });
await deleteDoc(doc(db, 'todos', id));
// 5) await vs fire-and-forget
// • await — resolves when the LOCAL cache acknowledges (works offline)
// • The promise of a write WITHOUT a resolve listener doesn't wait for the server
// • To know when the SERVER has the write, attach a listener and watch hasPendingWrites flip to false
// 6) Detecting network status
import { enableNetwork, disableNetwork } from 'firebase/firestore';
await disableNetwork(db); // simulate offline
await enableNetwork(db); // resume
// 7) Realtime Database offline
import { getDatabase, goOffline, goOnline, ref, onValue, set } from 'firebase/database';
import { initializeApp } from 'firebase/app';
const rt = getDatabase(initializeApp(firebaseConfig));
// Keep a node fresh in cache
import { ref as refRT, keepSynced } from 'firebase/database';
keepSynced(refRT(rt, 'chats/general'), true);
// Manual control
await goOffline(rt);
await goOnline(rt);
// Reads via onValue fire from cache while offline.
// 8) Conflict semantics
// • Firestore uses LAST WRITER WINS at the field level (sort of). When the device reconnects,
// queued writes are applied IN ORDER. The final state is whatever the latest update sets.
// • For multi-user collaborative apps, you usually want field-level merge:
import { updateDoc, increment, arrayUnion, serverTimestamp } from 'firebase/firestore';
await updateDoc(doc(db, 'counters', 'visits'), {
total: increment(1), // CRDT-style atomic add
lastSeen: { [uid]: serverTimestamp() },
});
// increment / arrayUnion / arrayRemove are commutative — safe when many users write concurrently.
// 9) Transactions don't run offline
// • Firestore transactions REQUIRE the server (round trip)
// • Use runTransaction only when online; otherwise the call hangs until network is restored
import { runTransaction } from 'firebase/firestore';
await runTransaction(db, async (tx) => {
const ref = doc(db, 'counters', 'visits');
const snap = await tx.get(ref);
tx.update(ref, { total: (snap.data()?.total ?? 0) + 1 });
});
// 10) Auth + offline
// Once a user is signed in, their session persists offline.
// New sign-ins require network. Show the offline state explicitly in your auth UI.
import { onAuthStateChanged } from 'firebase/auth';
onAuthStateChanged(auth, (user) => { /* may fire from cached creds even offline */ });
// 11) Storage uploads / downloads — Resumable
import { ref as sref, uploadBytesResumable, getDownloadURL } from 'firebase/storage';
const task = uploadBytesResumable(sref(storage, `uploads/${file.name}`), file);
task.on('state_changed',
(s) => console.log('progress', s.bytesTransferred, s.totalBytes),
(e) => console.error('upload err', e),
() => getDownloadURL(task.snapshot.ref).then(console.log),
);
// Resumable uploads survive temporary disconnects — they pick up where they left off.
// 12) Bundle data for offline-first apps
// Pre-load a slice of data your app needs immediately
import { loadBundle } from 'firebase/firestore';
const bundleResponse = await fetch('/data/initial-bundle.json');
const bundleBuffer = await bundleResponse.arrayBuffer();
await loadBundle(db, bundleBuffer);
// Now the listeners serve from cache instantly, even before the network comes up.
// 13) Cache size + eviction
// • Firestore Web: persistent cache uses IndexedDB; default ~ 40 MB (but you can set UNLIMITED)
// • Set explicit sizes on mobile — large caches degrade query speed
// • Eviction policy: LRU within the cache size budget
// 14) Showing offline UI
import { useEffect, useState } from 'react';
function useOnline() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
return online;
}
function Banner() {
const online = useOnline();
if (online) return null;
return <div className="offline-banner">You're offline — changes will sync when you reconnect.</div>;
}
// 15) Common bugs
// • Forgetting to enable persistence on web → cache disappears on tab close
// • Many tabs without persistentMultipleTabManager → only one tab gets persistence
// • Listening without { includeMetadataChanges: true } → no way to detect cache vs server source
// • Relying on runTransaction offline → call hangs; design alternate path with merge updates
// • Counter writes without increment() → last-write-wins overwrites concurrent edits
// • Storage uploads not resumable → fail on transient network blips; always use uploadBytesResumable
// • Cached doc id == temporary id; on first sync, server assigns a real id; treat local ids as opaque
// • Authenticated reads from rules — rules run on the SERVER; cache reads use the LAST known auth state
Why it matters
Turn on persistent local cache up front (web especially — mobile defaults are already on), use atomic operators like increment and arrayUnion so concurrent offline writes merge cleanly, and read metadata.fromCache + hasPendingWrites on snapshots to show users an honest “syncing” state.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import { enableIndexedDbPersistence } from 'firebase/firestore';
// Cache reads + queue writes for offline use
await enableIndexedDbPersistence(db);
Try it Yourself »
Discussion
Loading…