Offline Sync Debugging
How Firestore Offline Sync Works
Firestore's offline persistence is enabled by default on mobile (Flutter). Here's the key architecture:
- Local disk-backed cache: The SDK maintains a local copy of all documents the client has read or written.
- Write queue: Writes go to the local cache first, then are queued for server sync. The SDK handles retry automatically.
- Optimistic reads: Reads return from cache immediately, then merge server updates when available.
- Metadata flags:
metadata.isFromCache → true if the data was served from the local cache (no server confirmation)
metadata.hasPendingWrites → true if the document has local writes that haven't synced to the server yet
- Snapshot listeners:
snapshots() streams receive events for both local and remote changes, enabling real-time UI updates.
Key Implications for One By Two
- Expenses created offline are immediately visible to the creating user
- Other group members won't see the expense until it syncs
- Balance recalculations (Cloud Functions) only trigger after sync
- Conflict resolution depends on server timestamps and version fields
The 5 Common Sync Issues
Issue 1: Data Not Syncing to Server
Symptoms:
hasPendingWrites stays true indefinitely
- Other users don't see the changes
- No error thrown on the client
Diagnostic Steps:
Check network connectivity:
final result = await Connectivity().checkConnectivity();
debugPrint('Connectivity: $result');
Check pending writes count:
final snapshots = await FirebaseFirestore.instance
.collection('groups/$groupId/expenses')
.get();
final pendingCount = snapshots.docs
.where((d) => d.metadata.hasPendingWrites)
.length;
debugPrint('Pending writes: $pendingCount');
Check for security rule rejections:
- Pending writes that violate security rules will silently fail
- Check Firebase console → Firestore → Usage → Denied reads/writes
- Or check device logs for
PERMISSION_DENIED
Check Firestore queue in logs:
grep "PERMISSION_DENIED" app.log
grep "Sync.Queue" app.log | jq '.'
Solutions:
| Cause |
Solution |
| Network issue |
Wait for reconnect — SDK handles automatically. No action needed. |
| Security rules rejection |
Fix firestore.rules, test with emulator, redeploy |
| Corrupted local cache |
FirebaseFirestore.instance.clearPersistence() — caution: loses all unsynced local data |
| SDK bug (rare) |
Update cloud_firestore package, check GitHub issues |
Issue 2: Remote Changes Not Appearing Locally
Symptoms:
- Another user added an expense, but it doesn't appear on this device
- Data appears after app restart but not in real-time
Diagnostic Steps:
Is a snapshot listener active?
// This should be active for real-time updates:
FirebaseFirestore.instance
.collection('groups/$groupId/expenses')
.orderBy('createdAt', descending: true)
.snapshots() // ← Must use snapshots(), not get()
.listen((snapshot) { ... });
Is the query filtering out the data?
- Check
where clauses — are they too restrictive?
- Does the new document match the query's conditions?
Is the listener disposed prematurely?
- Check widget lifecycle — is the
StreamSubscription cancelled on dispose()?
- With Riverpod, check
ref.onDispose() cleanup
Check metadata:
.snapshots(includeMetadataChanges: true)
.listen((snapshot) {
debugPrint('From cache: ${snapshot.metadata.isFromCache}');
debugPrint('Docs: ${snapshot.docs.length}');
});
Solutions:
| Cause |
Solution |
Using get() instead of snapshots() |
Switch to snapshots() for real-time data |
| Query filter too restrictive |
Adjust where clauses to include the missing documents |
| Listener disposed |
Fix lifecycle management — ensure listener survives navigation |
Missing includeMetadataChanges |
Add it to debug, but it's not required for data updates |
Issue 3: Duplicate Entries After Sync
Symptoms:
- Same expense appears twice in the list
- Duplicates appear after going online from offline
Diagnostic Steps:
Check document IDs:
// Are you using device-generated UUIDs?
final id = const Uuid().v4(); // ✅ Good: deterministic per creation
// Or server-generated IDs?
final ref = collection.doc(); // ⚠️ Risk: retry creates new doc
Check for retry logic creating duplicates:
- Is there a manual retry mechanism on top of Firestore's built-in queue?
- Does the UI allow double-tap on "Save"?
Check Cloud Function triggers:
- Is
onExpenseCreated firing twice?
- Check Cloud Functions logs for duplicate invocations
Solutions:
| Cause |
Solution |
| Server-generated IDs with retry |
Use UUID v4 generated on device — same ID = same document |
| Double-tap on save button |
Debounce the save action, disable button after first tap |
| Non-idempotent Cloud Functions |
Make triggers idempotent — check if work already done before proceeding |
| UI not deduplicating |
Use document ID as list key, not list index |
Prevention pattern:
Future<void> addExpense(Expense expense) async {
// Use a deterministic ID generated on the client
final docRef = _firestore
.collection('groups/${expense.groupId}/expenses')
.doc(expense.id); // ← ID set by client
// set() with a known ID is idempotent
await docRef.set(expense.toJson());
}
Issue 4: Conflict Detection Not Working
Symptoms:
- Two users edit the same expense offline
- When both sync, one overwrites the other with no conflict warning
- Balance calculations are incorrect
Diagnostic Steps:
Check version field:
// Is version being incremented on every write?
await docRef.update({
...updatedFields,
'version': FieldValue.increment(1),
'updatedAt': FieldValue.serverTimestamp(),
});
Check conflict detection in Cloud Functions:
// Is the trigger comparing versions?
exports.onExpenseUpdated = onDocumentUpdated(
'groups/{groupId}/expenses/{expenseId}',
async (event) => {
const before = event.data?.before.data();
const after = event.data?.after.data();
if (before?.version === after?.version) {
// No real change, skip
return;
}
// Process the update...
}
);
Check server timestamp usage:
- Are you using
FieldValue.serverTimestamp() or DateTime.now()?
DateTime.now() uses device clock (unreliable offline)
Solutions:
| Cause |
Solution |
| No version field |
Add version: int field, increment on every update |
| Using device clock |
Use FieldValue.serverTimestamp() for updatedAt |
| No conflict detection |
Add version check in Cloud Function trigger |
| Last-write-wins without warning |
Implement optimistic locking: reject update if version != expected |
Optimistic locking pattern:
Future<Result<void>> updateExpense(Expense expense) async {
final docRef = _firestore.doc('groups/${expense.groupId}/expenses/${expense.id}');
return _firestore.runTransaction((txn) async {
final snapshot = await txn.get(docRef);
final serverVersion = snapshot.data()?['version'] ?? 0;
if (serverVersion != expense.version) {
throw ConflictException(
'Expense was modified by another user. Please refresh and try again.',
);
}
txn.update(docRef, {
...expense.toJson(),
'version': serverVersion + 1,
'updatedAt': FieldValue.serverTimestamp(),
});
});
}
Issue 5: Stale Data After Reconnect
Symptoms:
- App shows old data even after going back online
- New data only appears after force-closing and reopening the app
isFromCache stays true after reconnection
Diagnostic Steps:
Check listener type:
// ❌ One-shot get — never updates
final snapshot = await collection.get();
// ✅ Real-time listener — updates automatically
collection.snapshots().listen((snapshot) { ... });
Check for hardcoded cache source:
// ❌ This forces cache-only reads
final snapshot = await collection.get(GetOptions(source: Source.cache));
Search codebase: grep -r "Source.cache" lib/
Check listener lifecycle:
- Was the listener disposed on network disconnect and not re-created?
- Are listeners being recreated on route push/pop?
Check Riverpod provider lifecycle:
// Is the provider being disposed when it shouldn't be?
// autoDispose providers are disposed when no listener is active
final expensesProvider = StreamProvider.autoDispose.family<List<Expense>, String>(
(ref, groupId) {
ref.keepAlive(); // ← Consider this if data should persist
return repo.watchExpenses(groupId);
},
);
Solutions:
| Cause |
Solution |
Using get() for real-time data |
Switch to snapshots() |
Hardcoded Source.cache |
Remove explicit source option — let SDK decide |
| Listener disposed on disconnect |
Don't dispose listeners on network change — SDK handles reconnection |
autoDispose too aggressive |
Use ref.keepAlive() or remove autoDispose for critical data |
| Stale provider state |
Invalidate the provider: ref.invalidate(expensesProvider(groupId)) |
Debugging Commands
# Check for pending writes in logs
grep "hasPendingWrites.*true" app.log | jq '.'
# Check for security rule rejections
grep "PERMISSION_DENIED" app.log | jq '.'
# Check listener lifecycle events
grep "FS.Listen" app.log | jq 'select(.msg | contains("start") or contains("end"))'
# Check sync queue status
grep "Sync.Queue" app.log | jq '.'
# Check network state changes
grep "connectivity" app.log | jq '.'
# Check for duplicate document writes
grep "FS.Write" app.log | jq '.docId' | sort | uniq -d
Offline Sync Testing Checklist
Manual Testing Scenarios
Automated Testing
Architecture Guidelines for Offline-First
- Always use
snapshots() for data that should update in real-time
- Generate document IDs on the client (UUID v4) for idempotent writes
- Use
FieldValue.serverTimestamp() for all timestamp fields
- Increment version fields on every update for conflict detection
- Never use
Source.cache explicitly — let the SDK manage cache strategy
- Design Cloud Functions to be idempotent — they may fire multiple times
- Show sync status in the UI — users should know when data is pending sync
Converted and distributed by TomeVault — claim your Tome and manage your conversions.
1---2name: offline-sync-debugging3description: Systematic debugging guide for Firestore offline sync issues. Covers the 5 most common sync problems with diagnostic steps and solutions. Use when this capability is needed.4---56# Offline Sync Debugging78## How Firestore Offline Sync Works910Firestore's offline persistence is enabled by default on mobile (Flutter). Here's the key architecture:11121. **Local disk-backed cache:** The SDK maintains a local copy of all documents the client has read or written.132. **Write queue:** Writes go to the local cache first, then are queued for server sync. The SDK handles retry automatically.143. **Optimistic reads:** Reads return from cache immediately, then merge server updates when available.154. **Metadata flags:**16 - `metadata.isFromCache` → `true` if the data was served from the local cache (no server confirmation)17 - `metadata.hasPendingWrites` → `true` if the document has local writes that haven't synced to the server yet185. **Snapshot listeners:** `snapshots()` streams receive events for both local and remote changes, enabling real-time UI updates.1920### Key Implications for One By Two2122- Expenses created offline are immediately visible to the creating user23- Other group members won't see the expense until it syncs24- Balance recalculations (Cloud Functions) only trigger after sync25- Conflict resolution depends on server timestamps and version fields2627---2829## The 5 Common Sync Issues3031### Issue 1: Data Not Syncing to Server3233**Symptoms:**3435- `hasPendingWrites` stays `true` indefinitely36- Other users don't see the changes37- No error thrown on the client3839**Diagnostic Steps:**40411. **Check network connectivity:**4243 ```dart44 final result = await Connectivity().checkConnectivity();45 debugPrint('Connectivity: $result');46 ```47482. **Check pending writes count:**4950 ```dart51 final snapshots = await FirebaseFirestore.instance52 .collection('groups/$groupId/expenses')53 .get();54 final pendingCount = snapshots.docs55 .where((d) => d.metadata.hasPendingWrites)56 .length;57 debugPrint('Pending writes: $pendingCount');58 ```59603. **Check for security rule rejections:**61 - Pending writes that violate security rules will silently fail62 - Check Firebase console → Firestore → Usage → Denied reads/writes63 - Or check device logs for `PERMISSION_DENIED`64654. **Check Firestore queue in logs:**6667 ```bash68 grep "PERMISSION_DENIED" app.log69 grep "Sync.Queue" app.log | jq '.'70 ```7172**Solutions:**7374| Cause | Solution |75|-------|----------|76| Network issue | Wait for reconnect — SDK handles automatically. No action needed. |77| Security rules rejection | Fix `firestore.rules`, test with emulator, redeploy |78| Corrupted local cache | `FirebaseFirestore.instance.clearPersistence()` — **caution:** loses all unsynced local data |79| SDK bug (rare) | Update `cloud_firestore` package, check GitHub issues |8081---8283### Issue 2: Remote Changes Not Appearing Locally8485**Symptoms:**8687- Another user added an expense, but it doesn't appear on this device88- Data appears after app restart but not in real-time8990**Diagnostic Steps:**91921. **Is a snapshot listener active?**9394 ```dart95 // This should be active for real-time updates:96 FirebaseFirestore.instance97 .collection('groups/$groupId/expenses')98 .orderBy('createdAt', descending: true)99 .snapshots() // ← Must use snapshots(), not get()100 .listen((snapshot) { ... });101 ```1021032. **Is the query filtering out the data?**104 - Check `where` clauses — are they too restrictive?105 - Does the new document match the query's conditions?1061073. **Is the listener disposed prematurely?**108 - Check widget lifecycle — is the `StreamSubscription` cancelled on `dispose()`?109 - With Riverpod, check `ref.onDispose()` cleanup1101114. **Check metadata:**112113 ```dart114 .snapshots(includeMetadataChanges: true)115 .listen((snapshot) {116 debugPrint('From cache: ${snapshot.metadata.isFromCache}');117 debugPrint('Docs: ${snapshot.docs.length}');118 });119 ```120121**Solutions:**122123| Cause | Solution |124|-------|----------|125| Using `get()` instead of `snapshots()` | Switch to `snapshots()` for real-time data |126| Query filter too restrictive | Adjust `where` clauses to include the missing documents |127| Listener disposed | Fix lifecycle management — ensure listener survives navigation |128| Missing `includeMetadataChanges` | Add it to debug, but it's not required for data updates |129130---131132### Issue 3: Duplicate Entries After Sync133134**Symptoms:**135136- Same expense appears twice in the list137- Duplicates appear after going online from offline138139**Diagnostic Steps:**1401411. **Check document IDs:**142143 ```dart144 // Are you using device-generated UUIDs?145 final id = const Uuid().v4(); // ✅ Good: deterministic per creation146 // Or server-generated IDs?147 final ref = collection.doc(); // ⚠️ Risk: retry creates new doc148 ```1491502. **Check for retry logic creating duplicates:**151 - Is there a manual retry mechanism on top of Firestore's built-in queue?152 - Does the UI allow double-tap on "Save"?1531543. **Check Cloud Function triggers:**155 - Is `onExpenseCreated` firing twice?156 - Check Cloud Functions logs for duplicate invocations157158**Solutions:**159160| Cause | Solution |161|-------|----------|162| Server-generated IDs with retry | Use UUID v4 generated on device — same ID = same document |163| Double-tap on save button | Debounce the save action, disable button after first tap |164| Non-idempotent Cloud Functions | Make triggers idempotent — check if work already done before proceeding |165| UI not deduplicating | Use document ID as list key, not list index |166167**Prevention pattern:**168169```dart170Future<void> addExpense(Expense expense) async {171 // Use a deterministic ID generated on the client172 final docRef = _firestore173 .collection('groups/${expense.groupId}/expenses')174 .doc(expense.id); // ← ID set by client175176 // set() with a known ID is idempotent177 await docRef.set(expense.toJson());178}179```180181---182183### Issue 4: Conflict Detection Not Working184185**Symptoms:**186187- Two users edit the same expense offline188- When both sync, one overwrites the other with no conflict warning189- Balance calculations are incorrect190191**Diagnostic Steps:**1921931. **Check `version` field:**194195 ```dart196 // Is version being incremented on every write?197 await docRef.update({198 ...updatedFields,199 'version': FieldValue.increment(1),200 'updatedAt': FieldValue.serverTimestamp(),201 });202 ```2032042. **Check conflict detection in Cloud Functions:**205206 ```typescript207 // Is the trigger comparing versions?208 exports.onExpenseUpdated = onDocumentUpdated(209 'groups/{groupId}/expenses/{expenseId}',210 async (event) => {211 const before = event.data?.before.data();212 const after = event.data?.after.data();213 if (before?.version === after?.version) {214 // No real change, skip215 return;216 }217 // Process the update...218 }219 );220 ```2212223. **Check server timestamp usage:**223 - Are you using `FieldValue.serverTimestamp()` or `DateTime.now()`?224 - `DateTime.now()` uses device clock (unreliable offline)225226**Solutions:**227228| Cause | Solution |229|-------|----------|230| No version field | Add `version: int` field, increment on every update |231| Using device clock | Use `FieldValue.serverTimestamp()` for `updatedAt` |232| No conflict detection | Add version check in Cloud Function trigger |233| Last-write-wins without warning | Implement optimistic locking: reject update if `version != expected` |234235**Optimistic locking pattern:**236237```dart238Future<Result<void>> updateExpense(Expense expense) async {239 final docRef = _firestore.doc('groups/${expense.groupId}/expenses/${expense.id}');240241 return _firestore.runTransaction((txn) async {242 final snapshot = await txn.get(docRef);243 final serverVersion = snapshot.data()?['version'] ?? 0;244245 if (serverVersion != expense.version) {246 throw ConflictException(247 'Expense was modified by another user. Please refresh and try again.',248 );249 }250251 txn.update(docRef, {252 ...expense.toJson(),253 'version': serverVersion + 1,254 'updatedAt': FieldValue.serverTimestamp(),255 });256 });257}258```259260---261262### Issue 5: Stale Data After Reconnect263264**Symptoms:**265266- App shows old data even after going back online267- New data only appears after force-closing and reopening the app268- `isFromCache` stays `true` after reconnection269270**Diagnostic Steps:**2712721. **Check listener type:**273274 ```dart275 // ❌ One-shot get — never updates276 final snapshot = await collection.get();277278 // ✅ Real-time listener — updates automatically279 collection.snapshots().listen((snapshot) { ... });280 ```2812822. **Check for hardcoded cache source:**283284 ```dart285 // ❌ This forces cache-only reads286 final snapshot = await collection.get(GetOptions(source: Source.cache));287 ```288289 Search codebase: `grep -r "Source.cache" lib/`2902913. **Check listener lifecycle:**292 - Was the listener disposed on network disconnect and not re-created?293 - Are listeners being recreated on route push/pop?2942954. **Check Riverpod provider lifecycle:**296297 ```dart298 // Is the provider being disposed when it shouldn't be?299 // autoDispose providers are disposed when no listener is active300 final expensesProvider = StreamProvider.autoDispose.family<List<Expense>, String>(301 (ref, groupId) {302 ref.keepAlive(); // ← Consider this if data should persist303 return repo.watchExpenses(groupId);304 },305 );306 ```307308**Solutions:**309310| Cause | Solution |311|-------|----------|312| Using `get()` for real-time data | Switch to `snapshots()` |313| Hardcoded `Source.cache` | Remove explicit source option — let SDK decide |314| Listener disposed on disconnect | Don't dispose listeners on network change — SDK handles reconnection |315| `autoDispose` too aggressive | Use `ref.keepAlive()` or remove `autoDispose` for critical data |316| Stale provider state | Invalidate the provider: `ref.invalidate(expensesProvider(groupId))` |317318---319320## Debugging Commands321322```bash323# Check for pending writes in logs324grep "hasPendingWrites.*true" app.log | jq '.'325326# Check for security rule rejections327grep "PERMISSION_DENIED" app.log | jq '.'328329# Check listener lifecycle events330grep "FS.Listen" app.log | jq 'select(.msg | contains("start") or contains("end"))'331332# Check sync queue status333grep "Sync.Queue" app.log | jq '.'334335# Check network state changes336grep "connectivity" app.log | jq '.'337338# Check for duplicate document writes339grep "FS.Write" app.log | jq '.docId' | sort | uniq -d340```341342---343344## Offline Sync Testing Checklist345346### Manual Testing Scenarios347348- [ ] Create expense offline → verify it appears locally immediately349- [ ] Go online → verify expense syncs (hasPendingWrites becomes false)350- [ ] Verify other users see the expense after sync351- [ ] Create expense offline → close app → reopen online → verify sync352- [ ] Two users edit same expense offline → verify conflict is detected353- [ ] Delete expense offline → verify deletion syncs correctly354- [ ] Verify balance recalculation triggers after sync355356### Automated Testing357358- [ ] Test with `FakeFirebaseFirestore` for offline scenarios359- [ ] Test `hasPendingWrites` metadata handling360- [ ] Test conflict detection logic with version mismatches361- [ ] Test idempotent operations (same write twice = same result)362363---364365## Architecture Guidelines for Offline-First3663671. **Always use `snapshots()`** for data that should update in real-time3682. **Generate document IDs on the client** (UUID v4) for idempotent writes3693. **Use `FieldValue.serverTimestamp()`** for all timestamp fields3704. **Increment version fields** on every update for conflict detection3715. **Never use `Source.cache` explicitly** — let the SDK manage cache strategy3726. **Design Cloud Functions to be idempotent** — they may fire multiple times3737. **Show sync status in the UI** — users should know when data is pending sync374375---376> Converted and distributed by [TomeVault](https://tomevault.io/claim/avtansh-code) — claim your Tome and manage your conversions.377<!-- tomevault:4.0:skill_md:2026-04-15 -->