A user edits a record with no connection, the row updates, and the app confirms the change. Twenty minutes later the device reconnects, the queued write reaches the server, and a security rule refuses it. The app says nothing. The record on screen still shows the edit, the database still holds the old value, and the person who typed it has no reason to look again.
This is a presentation problem built on a real guarantee. Firestore's offline cache is doing what it documents: it applies the write locally, tells your listeners immediately, and queues the write for the server. What it never promised is that the server would accept it. If your UI treats the local write as the finished transaction, every refusal after reconnection becomes invisible.
The repair has three parts. Bind the success label to the pending-write flag rather than to the write call, keep the typed values in a record the rejection cannot touch, and verify against the server before clearing that record. The sections below work through each one, then give you a sequence that produces a late refusal on demand so you can watch your own app handle it.
What a write issued offline actually promises
Two documented behaviors create the illusion of success. First, latency compensation: "Local writes in your app will invoke snapshot listeners immediately", and "When you perform a write, your listeners will be notified with the new data before the data is sent to the backend" (Firebase, Get realtime updates, read 2026-10-21). Second, queueing: with the network unavailable, "Write operations are queued until network access is re-enabled" (Firebase, Access data offline, read 2026-10-21).
Neither statement mentions authorization, because authorization is not evaluated on the device. Firebase describes its rules as server-enforced, with access denied by default when no rule allows the method (Firebase, How Security Rules work). The cache holds your write; the server decides on it later.
The Dart API is equally specific about what it does not cover. The documentation for DocumentReference.set in cloud_firestore 7.0.0 describes writing and merging data, and says nothing about when the returned Future completes or how the call behaves offline (pub.dev, DocumentReference.set). The documented point where the backend enters the picture is elsewhere: waitForPendingWrites() "Waits until all currently pending writes for the active user have been acknowledged by the backend" (pub.dev, waitForPendingWrites).
So treat the write call as a request you have handed to the cache, and treat acknowledgment as a separate event you have to observe. A UI that awaits the write and renders a checkmark when the await returns is reading one of these as if it were the other.
Four states hide behind the word saved
Most forms ship with two states, in progress and done. An app with offline persistence needs four, and three of them are readable straight from snapshot metadata. SnapshotMetadata in cloud_firestore 7.0.0 exposes hasPendingWrites, "Whether the snapshot contains the result of local writes that have not yet been committed to the backend", and isFromCache, "Whether the snapshot was created from cached data rather than guaranteed up-to-date server data" (pub.dev, SnapshotMetadata).
| State | How you read it | What the user should see |
|---|---|---|
| Saved on this device | hasPendingWrites is true and the device is offline | Stored here, not sent yet |
| Syncing | hasPendingWrites is true and the device is online | Sending your change |
| Confirmed | hasPendingWrites and isFromCache are both false | Saved |
| Refused | The write reported an error, or a server read still shows the old value | What failed, and a retry built from the input |
A snapshot carrying no pending write that still comes from the cache proves nothing either way. Hold the state you had and let the reconciliation pass further down settle it, rather than promoting a cached read to a confirmation.
One detail decides whether you ever see the transition. Metadata changes do not raise listener events by default: "By default, no event is raised if only the SnapshotMetadata changed" (Firebase, Access data offline). The Dart listener takes the flag that turns them on, with the signature snapshots({bool includeMetadataChanges = false, ListenSource source = ListenSource.defaultSource}) (pub.dev, DocumentReference.snapshots).
enum EditState { savedOnDevice, syncing, confirmed, refused }
/// Returns null when the snapshot proves nothing: no write of ours is pending
/// and the data still came from the cache, so acknowledgment is unknown.
EditState? stateOf(
DocumentSnapshot<Map<String, dynamic>> snapshot, {
required bool deviceOnline,
required bool draftRefused,
}) {
if (draftRefused) return EditState.refused;
if (snapshot.metadata.hasPendingWrites) {
return deviceOnline ? EditState.syncing : EditState.savedOnDevice;
}
if (snapshot.metadata.isFromCache) return null;
return EditState.confirmed;
}
Stream<EditState?> watch(
DocumentReference<Map<String, dynamic>> target, {
required bool Function() deviceOnline,
required bool Function() draftRefused,
}) {
// Without includeMetadataChanges the pending-to-confirmed transition
// produces no event, because only the metadata changed.
return target.snapshots(includeMetadataChanges: true).map(
(snapshot) => stateOf(
snapshot,
deviceOnline: deviceOnline(),
draftRefused: draftRefused(),
),
);
}
The label matters as much as the flag. "Saved on this device" is a claim about the phone and stays true whatever the server decides. "Saved" is a claim about your database, and while hasPendingWrites is true you cannot make it.
Why the refusal arrives after the reconnect
The rejection is late because the conditions that produce it change while the device is away. Three cases cover most of them.
Membership is revoked. The rule allows the write when the user is a member of a team or project, an admin removes them, and the queued write now fails the same rule that passed when the screen was open.
The parent record is gone. The rule requires an existing parent document or a field on it, and someone deleted or archived it. Firestore evaluates rules per request, and when no rule allows the method the request is denied by default (Firebase, How Security Rules work).
The write was never allowed. Development against permissive rules hides a field the production rule set rejects. Online you would have seen it immediately. Offline the queue delays the discovery until reconnection, so a rule change can reach you as a bug report days after you deployed it.
There is a cost note worth knowing before you add retries. Reads denied by rules are still billable: "you will be billed for reading documents even if your rules reject the request" (Firebase, Writing conditions). Reconnection has its own charge: "If offline persistence is enabled and the listener is disconnected for more than 30 minutes (for example, if the user goes offline), you will be charged for documents and index entries read as if you had issued a brand-new query" (Firebase, Understand Cloud Firestore billing). A retry path that detaches and reattaches a listener after a long offline stretch pays that query charge again, so drive retries from a user action rather than a timer.
Keep the input where the rejection cannot reach it
When the write fails, the form that produced it has usually been closed, and the typed values went with it. Recovery means the field values have to live somewhere else before the write is issued, under a path whose own rule does not depend on the thing being revoked. A document owned by the user works for revoked membership and for a deleted parent, because the rule on it tests only the signed-in uid.
/// Records the intent under users/{uid}/pending_edits/{id}, then issues the
/// write. Neither call is awaited before the UI updates, because the local
/// cache applies both immediately while the server answer arrives later.
void submitEdit({
required String uid,
required DocumentReference<Map<String, dynamic>> target,
required Map<String, dynamic> fields,
}) {
final db = FirebaseFirestore.instance;
final draft =
db.collection('users').doc(uid).collection('pending_edits').doc();
draft.set({
'targetPath': target.path,
'fields': fields,
'status': 'pending',
// A server timestamp has no final value until the backend sets one, which
// is what GetOptions.serverTimestampBehavior exists to control, so order
// drafts by the device clock instead.
'createdAt': Timestamp.now(),
});
target.update(fields).then((_) {
draft.update({'status': 'confirmed'});
}).catchError((Object error) {
// Log the code the first time instead of designing against a guess: the
// write API documentation does not state which code a queued write
// carries when the server refuses it after reconnection.
final code = error is FirebaseException ? error.code : 'unknown';
draft.update({'status': 'refused', 'errorCode': code});
});
}
That callback has one gap you should plan for rather than discover. If the process is killed while the write is still queued, the queue outlives it: waitForPendingWrites() waits for previously issued writes "(including those written in a previous app session)" (pub.dev, waitForPendingWrites). The callback does not. A write can therefore be accepted or refused with no code of yours listening, which makes a reconciliation pass at startup part of the design and not an optimization.
Future<void> reconcile({
required DocumentReference<Map<String, dynamic>> draft,
required DocumentReference<Map<String, dynamic>> target,
required Map<String, dynamic> fields,
}) async {
try {
// Rejected during user changes, so a sign-out mid-wait lands here.
await FirebaseFirestore.instance.waitForPendingWrites();
} on FirebaseException catch (_) {
return;
}
// Source.server refuses the cached answer, which is the only way to learn
// what the database holds rather than what this device believes.
final fresh = await target.get(const GetOptions(source: Source.server));
final data = fresh.data() ?? const <String, dynamic>{};
// Scalar comparison only. Nested maps and arrays need a deeper check.
final applied = fields.entries.every((entry) => data[entry.key] == entry.value);
await draft.update({'status': applied ? 'confirmed' : 'refused'});
}
GetOptions configures get() calls to read from the server, from the cache, or from the server with a cache fallback, which is the default (pub.dev, GetOptions). Clear the pending record only after a confirmed server read. Deleting it when the write call returns puts you back where this article started.
Reproduce the refusal on purpose
Run this against the emulator or a throwaway project, never against production data. useFirestoreEmulator(String host, int port, {bool sslEnabled = false, bool automaticHostMapping = true}) "Must be called immediately, prior to accessing FirebaseFirestore methods" (pub.dev, useFirestoreEmulator), and the emulator "clears database contents when shut down", with export and import available if you want a fixed baseline (Firebase, Connect to the Cloud Firestore emulator).
- Sign in, open the record, and confirm the edit works online. This separates a rules bug from the timing you are about to test.
- Call
FirebaseFirestore.instance.disableNetwork()instead of toggling airplane mode. The documented behavior is the same queue, and you keep the debugger attached. - Submit the edit. Check that the row says it is stored on the device and that the pending record exists with status pending.
- While still offline, change authorization so the write can no longer pass. Removing the user from the members list, or deleting the parent document through the emulator UI, both work.
- Call
enableNetwork()and log whatever the write call reports: the error type,error.codeanderror.message.FirebaseExceptioncarriescodeas "The optional code to accommodate the message" andmessageas "The long form message of the exception" (pub.dev, FirebaseException). Write down the exact string you get, because that is the value your own code should branch on. - Read the document with
GetOptions(source: Source.server)and confirm it still holds the old value. - Kill the app between steps 3 and 5 and run it again. This is the path your callbacks miss and your reconciliation has to cover.
The run is finished when the record shows a refusal, the typed values are still readable from the pending document, and no screen claims the server accepted anything.
Diagnostic checklist
| Symptom | Read this first | Usual cause |
|---|---|---|
| Offline edit shows a plain confirmation | Which flag the label is bound to | The UI renders success on the write call instead of on hasPendingWrites turning false |
| State never moves from pending to confirmed | Whether the listener sets includeMetadataChanges | A metadata-only change raises no event by default |
| Server value is old, app value is new, no error anywhere | Whether anything observes the write Future or reconciles at startup | The refusal arrived while no callback existed, usually after a restart |
| Refusal appears only in production | The deployed rules against the ones you develop with | A field or path the permissive local rule set allows |
| Draft disappears with the failed write | The path the draft is written to | The draft lives under the same document whose rule now refuses the user |
| Edit works on web, fails on mobile, or the reverse | Whether persistence is on for that platform | Persistence is enabled by default on Android and Apple platforms and disabled by default on the web |
| Reconnect produces a bill spike | How often listeners are detached and reattached | Each listener offline for more than 30 minutes is re-charged as a new query |
What offline persistence does not buy you
It does not grant permission, and it does not promise acceptance. Beyond that, four documented limits shape what you can offer offline.
Transactions are out. "Transactions will fail when the client is offline" (Firebase, Transactions and batched writes). Anything that needs a read-modify-write under contention has to wait for connectivity, so a counter or a seat reservation cannot be an offline feature.
Repeated edits collapse. "For multiple changes to the same document, it's last write wins" (Firebase, Access data offline). Two people editing the same record offline is a conflict problem, which is a different question from this one. Choosing where that boundary sits is covered in how offline-first Flutter field tools handle writes and sync.
Platform defaults differ. Persistence is enabled by default on Android and Apple platforms and disabled by default for the web, where it is supported only in Chrome, Safari and Firefox, and the web cache "isn't automatically cleared between sessions". The documented on-disk threshold in the platform examples is 100 MB, with a 1 MB minimum and a constant for disabling cleanup (Firebase, Access data offline; pub.dev, Settings).
Some reads have no offline path. Getting a single uncached document offline returns an error rather than empty data, and "Pipeline operations don't support offline persistence" (Firebase, Access data offline).
Where FlutterFlow's built-in actions stop
FlutterFlow's Update Document action documents the document reference and the Set Fields configuration. Its page describes no failure branch, no error callback and no offline behavior, and the Batch Write section states that "either all the operations within the batch will succeed or none of them will be applied" (FlutterFlow, Firestore Actions). So the built-in action can issue the write, and the four states, the pending record and the reconciliation pass need custom code with cloud_firestore.
Backend query caching is a separate mechanism and it is worth not confusing with persistence. FlutterFlow documents it as storing a query result so it "can enable your app to function offline by serving cached results when there is no internet connection", scoped at App Level or Page Level, invalidated through the Should Override Cache property or the Clear Query Cache action, with explicit clearing called the better practice and sensitive data called out as something that "should not be cached" (FlutterFlow, Backend Query). That cache serves reads. It has nothing to say about whether your queued write was accepted.
If you are deciding the data layer rather than fixing one screen, the Flutter and Firebase SaaS stack decisions covers where this belongs in the architecture.
Start with the label
The behavior quoted above was read on 2026-10-21 at the links beside each claim. The state machine, the pending record and the reconciliation pass are a design built on top of that behavior, not documented API guarantees. The Dart snippets are reference implementations checked against the published signatures for cloud_firestore 7.0.0 and firebase_core 5.0.0, and none of them was executed against a Firebase project or a device while writing, so the seven-step sequence is the part that produces evidence in your app.
Open the screen that worries you most and find the line that renders the success state. If it is bound to the completion of the write call rather than to hasPendingWrites turning false, you have found the defect, and the first correction is a label that names the device instead of the database.


