Skip to content
Tech Stack21 October 2026 · 14 min read

When Offline Firestore Writes Are Rejected Later

Firestore applies an offline write to the cache and notifies listeners at once. The server answers later, and a refusal after reconnection stays invisible unless the UI reads four states.

When Offline Firestore Writes Are Rejected Later

What you'll learn

  • What a write issued offline actually promises
  • Four states hide behind the word saved
  • Why the refusal arrives after the reconnect
Article contents

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

Two phones side by side: the left marks four list rows with the same small dot, the right marks the same rows with a filled square, an open ring, a half filled ring and an amber cross.

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).

StateHow you read itWhat the user should see
Saved on this devicehasPendingWrites is true and the device is offlineStored here, not sent yet
SyncinghasPendingWrites is true and the device is onlineSending your change
ConfirmedhasPendingWrites and isFromCache are both falseSaved
RefusedThe write reported an error, or a server read still shows the old valueWhat 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

A phone showing one list row crossed out in amber, beside a clipped index card that still carries all four recorded values.

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).

  1. Sign in, open the record, and confirm the edit works online. This separates a rules bug from the timing you are about to test.
  2. Call FirebaseFirestore.instance.disableNetwork() instead of toggling airplane mode. The documented behavior is the same queue, and you keep the debugger attached.
  3. Submit the edit. Check that the row says it is stored on the device and that the pending record exists with status pending.
  4. 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.
  5. Call enableNetwork() and log whatever the write call reports: the error type, error.code and error.message. FirebaseException carries code as "The optional code to accommodate the message" and message as "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.
  6. Read the document with GetOptions(source: Source.server) and confirm it still holds the old value.
  7. 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

SymptomRead this firstUsual cause
Offline edit shows a plain confirmationWhich flag the label is bound toThe UI renders success on the write call instead of on hasPendingWrites turning false
State never moves from pending to confirmedWhether the listener sets includeMetadataChangesA metadata-only change raises no event by default
Server value is old, app value is new, no error anywhereWhether anything observes the write Future or reconciles at startupThe refusal arrived while no callback existed, usually after a restart
Refusal appears only in productionThe deployed rules against the ones you develop withA field or path the permissive local rule set allows
Draft disappears with the failed writeThe path the draft is written toThe draft lives under the same document whose rule now refuses the user
Edit works on web, fails on mobile, or the reverseWhether persistence is on for that platformPersistence is enabled by default on Android and Apple platforms and disabled by default on the web
Reconnect produces a bill spikeHow often listeners are detached and reattachedEach 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.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Why does a Flutter app show an offline edit as saved when the server rejects it later?

Because the cache and the server answer at different moments, and only the first one is instant. Firestore applies the write locally and notifies listeners before the data is sent to the backend, which is documented as latency compensation, so a UI bound to the write call renders success before authorization has been checked anywhere. Rules run on the server, not on the device. When the queued write is refused after reconnection, nothing in the cached snapshot corrects the screen. Bind the success label to hasPendingWrites turning false instead of to the moment your write call returns, and the claim matches what the database actually holds.

How do you confirm that a Firestore write reached the server in Flutter?

Read the snapshot metadata and re-read from the server, rather than awaiting the write call. A snapshot whose hasPendingWrites and isFromCache are both false came from the backend with no local write outstanding. To receive that transition you have to listen with includeMetadataChanges: true, because a change to metadata alone raises no event by default. waitForPendingWrites() is documented as waiting until currently pending writes for the active user have been acknowledged by the backend, and a get with GetOptions(source: Source.server) shows the stored value rather than the cached one.

Does offline persistence mean a queued Firestore write will eventually succeed?

No. Persistence queues the write and sends it when connectivity returns, and it grants no permission at all. Security rules are evaluated by the server on each request, so a write queued while the user was a member of a project can be refused once that membership is revoked, once the parent document is deleted, or because the production rule set never allowed the field in the first place. Offline persistence changes when you learn the answer, not what the answer is, which is why the pending state and the confirmed state need different labels in the interface.

Where should typed values live so a rejected write does not lose them?

In a record whose own rule cannot be revoked by the same change, which usually means a document owned by the signed-in user. A path such as users/{uid}/pending_edits/{id} stays writable when membership on the target document is withdrawn or the parent record is deleted, because the rule on it tests only the uid. Write the field map there before issuing the real write, carry a status on it, and clear it only after a server-source read confirms the change landed. Clearing it as soon as the write call returns is the step that loses the input.

Can FlutterFlow built-in actions present these states without custom code?

Not on their own. The Update Document action documentation covers the document reference and the Set Fields configuration, and describes no failure branch, no error callback and no offline behavior, so the four states, the pending record and the reconciliation pass need custom code with cloud_firestore. Backend query caching is a separate feature: it can serve reads offline and is cleared through Should Override Cache or the Clear Query Cache action, and it reports nothing about whether a queued write was accepted.