Firestore offline persistence: the behaviour differences that surprise developers

By Priya Venkataraman · 31 July 20262,400 views
Firestore offline persistence: the behaviour differences that surprise developers

Quick summary: offline persistence isn't transparent

Firestore offline persistence is a powerful tool for mobile apps—until you're building a financial ledger. The moment you process transactions worth ₹2 B/day, offline persistence becomes a correctness risk, not a feature. This article cuts through the surprising gaps between what offline persistence looks like and what it actually does when real money is involved.

The core issue: developers assume offline writes queue like a normal message queue. They don't. Firestore offline persistence queues writes locally, but the reconciliation semantics with the server are drastically different from what most engineers expect. If you're building a payment system, real-time ledger, or any financial pipeline, these gaps will cost you audit findings.

The fundamental gap: local writes aren't server writes

When you execute an offline write in Firestore, here's what happens:

  1. The write completes immediately in the local cache
  2. Your application receives confirmation
  3. The write sits in a local queue until connectivity returns
  4. Once online, Firestore attempts to sync

The problem is deceptively simple: your application doesn't know if that write succeeded on the server until minutes later. In a financial ledger, this is disqualifying.

Consider a payment ledger where you process debits. A user pays ₹500. Offline persistence records the debit locally. The UI shows success. Thirty seconds later, the connection drops before the write reaches Firestore. When connectivity returns, Firestore will try to apply the debit again—but the server may have already processed a different transaction for the same user, and the debit will fail validation.

Your local cache shows the debit was applied. The server shows it wasn't. The user sees their balance as spent money. The ledger is now inconsistent.

This is why high-throughput financial systems don't use offline persistence for writes. Period.

Where offline persistence actually works: reads and UI state

Offline persistence shines in exactly one scenario: serving cached reads while offline and syncing state when online. If you're building a transaction history view, spending dashboard, or balance display that users might check while the connection is spotty, offline persistence is the right tool.

The key constraint: the reads must be informational, not decision-critical. A user checking "did my payment go through?" benefits from cached data. A system deciding "is this user eligible for a ₹10,000 transfer?" cannot use potentially-stale cached data.

Firestore's offline persistence for reads works well because:

  • Reads don't modify state
  • Stale reads are contextually acceptable (you tell the user when data was last synced)
  • Firestore syncs cached documents transparently when online

The mistake: developers enable offline persistence globally and assume it works the same way for writes.

Offline writes: the reconciliation problem

When Firestore comes back online, it attempts to apply queued writes in order. Here's what breaks in financial systems:

Conditional writes fail silently

Imagine your transaction write includes a precondition: "only apply this debit if the current balance is ≥ ₹500". Offline, you queue the write. Online, Firestore sends it to the server. The server checks the balance—and it's now ₹300 (another transaction went through). The precondition fails. The write is discarded.

Your local cache still shows the debit applied.

// This write queues locally and succeeds
// But fails on the server due to precondition
await FirebaseFirestore.instance
    .collection('ledger')
    .doc(userId)
    .update({
      'balance': FieldValue.increment(-500),
      'lastTxnAt': FieldValue.serverTimestamp(),
    },
    precondition: Precondition(exists: true),
);

// User sees balance updated locally
// Server rejects the write due to race condition
// Ledger is now split-brain

Concurrent offline writes compound the problem

Two writes queue offline. User goes online. Firestore applies both. But one was meant to depend on the other. The ledger now has duplicate transactions, or transactions in the wrong order, or both.

// User is offline, queues two writes
await ledgerCollection.doc(txnId1).set({
  'amount': 500,
  'type': 'debit',
  'timestamp': FieldValue.serverTimestamp(),
});

await ledgerCollection.doc(txnId2).set({
  'amount': 500,
  'type': 'debit',
  'timestamp': FieldValue.serverTimestamp(),
});

// Both queue locally. Both succeed locally.
// User sees ₹1000 deducted from their balance.
// Network comes back. Both writes hit the server.
// Server processes them in an order that may differ from local order.
// Balance reconciliation fails because the writes were applied against different base states.

Firestore doesn't expose write status

Here's the killer: Firestore offline persistence doesn't expose whether a write succeeded on the server. You get a completion callback when the local write finishes. That's it. Minutes later, when the network syncs, you don't know if it worked.

Your options are:

  1. Poll the document after the write completes (wasteful, unreliable)
  2. Don't use offline writes for critical transactions (correct)
  3. Implement your own request ID tracking and server-side idempotence (expensive, duplicates Firestore functionality)

Design pattern: offline persistence for reads, server-driven writes

For financial ledgers, the pattern that works is:

Writes: Always require online connectivity. Return an error if offline. Force the user to retry when online.

Reads: Use offline persistence aggressively for cached data, but always show a "last synced" timestamp so users know they're looking at cached data.

This separates concerns:

  • Ledger correctness (writes) is guaranteed because writes only happen when you can verify server state
  • User experience (reads) is smooth because you serve cached data offline

Here's the implementation pattern:

// Disable offline persistence for writes globally
FirebaseFirestore.instance.settings = const Settings(
  persistenceEnabled: true,
);

// Write function: requires connectivity
Future<void> recordLedgerEntry({
  required String userId,
  required double amount,
  required String txnId,
}) async {
  // Check connectivity first
  final connectivity = await Connectivity().checkConnectivity();
  if (connectivity == ConnectivityResult.none) {
    throw const OfflineException('Cannot process transaction while offline');
  }

  // Use a transaction to ensure atomicity
  await FirebaseFirestore.instance.runTransaction((transaction) async {
    final docRef = FirebaseFirestore.instance
        .collection('ledger')
        .doc(userId);
    
    final currentDoc = await transaction.get(docRef);
    final currentBalance = currentDoc['balance'] as double;
    
    // Validate balance before applying debit
    if (amount > 0 && currentBalance < amount) {
      throw InsufficientFundsException('Balance: ₹$currentBalance, Debit: ₹$amount');
    }
    
    // Record the ledger entry
    await transaction.set(
      FirebaseFirestore.instance.collection('ledger').doc(txnId),
      {
        'userId': userId,
        'amount': amount,
        'timestamp': FieldValue.serverTimestamp(),
        'status': 'applied',
      },
    );
    
    // Update balance atomically
    await transaction.update(docRef, {
      'balance': FieldValue.increment(amount),
      'lastUpdated': FieldValue.serverTimestamp(),
    });
  });
}

// Read function: uses offline persistence
Stream<LedgerEntry> watchLedgerEntries(String userId) {
  return FirebaseFirestore.instance
      .collection('ledger')
      .where('userId', isEqualTo: userId)
      .orderBy('timestamp', descending: true)
      .limit(50)
      .snapshots()
      .map((snapshot) => snapshot.docs
          .map((doc) => LedgerEntry.fromFirestore(doc))
          .toList());
}

The key differences:

  1. Writes check connectivity explicitly before executing
  2. Writes use transactions to ensure all-or-nothing semantics
  3. Writes validate preconditions on the server before applying
  4. Reads use snapshots() which automatically leverage offline persistence

Monitoring: detecting split-brain ledgers

Even with this pattern, you need to detect when offline persistence causes inconsistencies. At ₹2 B/day, one undetected split-brain ledger entry is unacceptable.

Implement three checks:

1. Write confirmation log

Every write should log a confirmation record after the server acknowledges it. Compare this log against your ledger. If a ledger entry has no corresponding confirmation, it failed server-side.

2. Balance reconciliation

Every hour, compute the expected balance by summing all confirmed ledger entries. Compare against the stored balance. Alert if they diverge by more than ₹0.01.

3. Offline write detection

Monitor Firestore's network state. If a write completes while offline, log it as a risk event. Track it separately until you confirm the server-side write succeeded.

// Risk detection: track writes that complete while offline
Future<void> recordLedgerEntryWithRiskTracking({
  required String userId,
  required double amount,
  required String txnId,
}) async {
  final connectivity = await Connectivity().checkConnectivity();
  
  if (connectivity == ConnectivityResult.none) {
    // Log as high-risk write that must be manually reconciled
    await riskEventCollection.add({
      'type': 'offline_write_attempted',
      'userId': userId,
      'amount': amount,
      'txnId': txnId,
      'timestamp': FieldValue.serverTimestamp(),
      'status': 'pending_confirmation',
    });
    throw const OfflineException('Transaction requires online connectivity');
  }
  
  try {
    await recordLedgerEntry(
      userId: userId,
      amount: amount,
      txnId: txnId,
    );
    
    // Mark risk event as confirmed
    await riskEventCollection.doc(txnId).update({
      'status': 'confirmed',
      'confirmedAt': FieldValue.serverTimestamp(),
    });
  } catch (e) {
    // Log failure for audit
    await riskEventCollection.doc(txnId).update({
      'status': 'failed',
      'error': e.toString(),
      'failedAt': FieldValue.serverTimestamp(),
    });
    rethrow;
  }
}

When a write fails server-side, you catch it here and can either retry with fresh preconditions or escalate to manual review.

Practical rules for financial systems

If you're building a ledger on Firestore:

  1. Disable offline persistence for all writes — require connectivity before attempting any state-changing operation
  2. Enable offline persistence only for reads — cache balances, transaction history, and UI state for offline viewing
  3. Use transactions for every multi-step operation — never update a balance without reading the current value first
  4. Implement request ID deduplication — every write must include a unique request ID so you can idempotently apply writes if the confirmation gets lost
  5. Monitor for precondition failures — when a write's precondition fails server-side, log it and alert
  6. Reconcile balances hourly — compute expected balance from the ledger and compare against stored balance

Offline persistence is a powerful feature for user experience. But in financial systems, user experience is a second priority to correctness. Build your system to guarantee correctness first, then add offline persistence for reads only.

At ₹2 B/day, one undetected ledger error doesn't just break a user's experience—it breaks audit compliance. Design to never have that undetected error in the first place.

Comments

No comments yet. Be the first!

Sign in to leave a comment.