Offline-first sync in Flutter + Firebase
Firestore hors ligne sur Flutter : le cache local comme source de vérité
The local cache, source of truth for the UI
get() or snapshots() returns the cached data immediately, then an update arrives if the server differs. The UI never waits on the network to display something.
PersistentCacheSettings replaces the old persistenceEnabled and cacheSizeBytes fields, still accepted but deprecated. The cache has a size cap by default: Firestore evicts the least recently used documents once the limit is reached, and CACHE_SIZE_UNLIMITED removes it. A one-off read can also force its source with GetOptions(source: Source.cache) or Source.server , the default serverAndCache falling back to the cache if the server doesn't respond.
Writes go into a queue
set , update , delete and WriteBatch join a local queue, applied to the cache immediately and replayed to the server once the network returns. Listeners attached to the document fire right away with the new value: this is what gives an optimistic UI without having to write rollback code.
waitForPendingWrites() returns a Future that completes once the queue drains, handy for an "everything is synced" indicator or to sequence a test.
Reading the snapshot metadata
SnapshotMetadata with two flags. isFromCache indicates that the data comes from the local cache and not from a server response. hasPendingWrites indicates that a local write is still waiting for confirmation. Together they describe a document's exact sync state.
includeMetadataChanges: true , the transition of hasPendingWrites to false (the write confirmed by the server) triggers no event, and the "syncing" badge never clears.
Connectivity informs the display
Conflicts: last-write-wins by default
FieldValue.increment() sidesteps the problem: the operation is commutative, so increments replayed from several devices add up instead of clobbering each other. To settle concurrent updates, an updatedAt field with FieldValue.serverTimestamp() gives a deterministic order, the timestamp being set by the server at sync time, not by the phone's clock.
serverTimestamp() reads as null in the local cache, which needs to be handled in the display.
runTransaction is the right tool, but it doesn't work offline: a transaction requires a round-trip to the server, it isn't queued and fails if the network is missing. This is intentional, since a transaction can't guarantee its invariant on a cache that might be stale. The logic that can't tolerate last-write-wins therefore lives server-side, in the transaction or a Cloud Function, and waits for the connection.
Testing offline behavior
disableNetwork() forces disconnected mode, writes pile up in the queue, enableNetwork() replays it.
With Firestore, the offline infrastructure is already in place: persistent cache, write queue, automatic sync on network return. The work of an offline-first app mostly comes down to trusting the local cache for both reads and writes, and not reflexively putting the network back at the center.