iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…