Skip to content
Tech Stack6 November 2026 · 16 min read

FlutterFlow Action Chains When One Step Fails

When a FlutterFlow submit saves the document but the photo upload fails, a retry can create a duplicate. Give the submission an ID first, make each step safe to repeat and send the notification from the server.

FlutterFlow Action Chains When One Step Fails

What you'll learn

  • Find out which step actually committed
  • Why an action chain is not a transaction
  • Give the submission an ID before the first write
Article contents

A user fills in a report, attaches a photo and taps Submit. The report document is written to Firestore, the photo upload fails, and the screen says "Something went wrong." The user taps Submit again and now there are two reports, one of them without a photo. In the opposite version of the same bug, the flow ends with a "Team notified" snackbar even though the step before it only opened an email app.

Both problems come from treating a FlutterFlow action chain as one operation that either fully succeeds or fully fails. It is a sequence of separate writes to separate systems. The fix is to give the submission an ID before the first write, make every step safe to repeat, store how far the submission got, show a message that matches the saved state, and hand the team notification to a server function that retries on its own.

The code below is a proposed reference implementation with synthetic data: a field report with a title, a body and one photo. The Cloud Function type-checks against firebase-functions 7.4.0. The retry protocol ran as an in-memory simulation with injected failures, restarts and duplicate events. The Dart custom actions were not compiled, and nothing here ran inside a FlutterFlow project or against a real Firebase backend. The test list near the end is what you should run in yours.

Find out which step actually committed

Before changing the flow, open the Firebase console and look at what the failed submission left behind. The record of what happened is in Firestore and Storage, not in the snackbar.

Diagram of a four-step FlutterFlow submission. Save report commits to Firestore, upload photo writes to Cloud Storage, attach photo path updates Firestore and notify team runs on the server. The upload step is marked as failed while the saved report remains.

Each step in the diagram is a separate write, so each can succeed or fail on its own. When the upload fails, the report written one step earlier stays in Firestore. Nothing in the chain removes it, and nothing should remove it automatically, for reasons covered further down.

What you seeWhat to checkLikely cause
Two reports with the same title, one without a photoDo both documents have auto-generated IDs?The retry ran Create Document again and got a new ID
Report exists, error message shownIs there a file under the report's Storage path?Upload failed after the document was committed
Photo in Storage, report has no photo fieldDid the step that writes the photo path run?Failure between upload and the follow-up update
"Team notified" shown, nobody received anythingWhat does the notify step actually do?A client-side step that cannot confirm delivery
Retry after an app restart starts from scratchIs any progress stored outside page state?Progress lived in a page variable that reset

Why an action chain is not a transaction

FlutterFlow's Action Flow Editor documentation says synchronous actions run "one after another, with each action waiting for the previous one to complete." Waiting for the previous step is ordering. It does not undo earlier steps when a later one fails. The same page does not describe what happens at runtime when an action throws, so check what your build shows before you rely on any particular behavior.

FlutterFlow does have an atomic option for one part of this. Its Firestore actions page describes Batch Firestore Writes, where "either all the operations within the batch will succeed or none of them will be applied." That guarantee covers Firestore document writes. A file upload goes to Cloud Storage and an email goes to a mail provider, so neither can join the batch. Firebase's transactions guide adds a second limit that matters on phones: "Transactions will fail when the client is offline," while batched writes still execute offline.

So the question to design for is "which steps have committed, and what happens on the next attempt?" Answer it once per step and the rest of the design follows.

Give the submission an ID before the first write

A retry creates a duplicate because Create Document generates a new ID each time it runs. Firestore's add data guide notes that .add(...) and .doc().set(...) "are equivalent" in the backend: both produce a fresh auto-generated identifier. Two taps, two IDs, two documents.

The same guide shows the alternative. Calling doc() on a collection creates a reference with an auto-generated ID without writing anything, so you can generate the ID first and use it later. Generate it once per logical submission, store it, and reuse it on every attempt:

// Custom action: newSubmissionId, returns String.
import 'package:cloud_firestore/cloud_firestore.dart';

Future<String> newSubmissionId() async {
  // Generates an ID locally. Nothing is written to Firestore yet.
  return FirebaseFirestore.instance.collection('submissions').doc().id;
}

FlutterFlow's Create Document action has a Custom ID toggle for using your own ID. Its documentation does not say whether running it again with an existing ID overwrites the document or fails, so I write the first save with a custom action where the behavior is documented. With set(), "if the document does not exist, it will be created," and if it exists its contents are overwritten unless you merge:

// Custom action: saveSubmission(submissionId, ownerUid, title, body).
import 'package:cloud_firestore/cloud_firestore.dart';

Future saveSubmission(
  String submissionId,
  String ownerUid,
  String title,
  String body,
) async {
  await FirebaseFirestore.instance
      .collection('submissions')
      .doc(submissionId)
      .set({
    'ownerUid': ownerUid,
    'title': title,
    'body': body,
  }, SetOptions(merge: true));
}

merge: true means a repeated save rewrites the same three fields and leaves later fields such as photoPath alone. Your security rules must allow the owner to update the document as well as create it, or the second attempt will be rejected.

Make every step safe to run twice

A step is safe to retry when running it a second time leaves the same end state as running it once. Check each step against that rule and decide what the user can do when it fails.

StepCommitted inSafe to run again?Required?When it fails
Save report (set with your ID)FirestoreYes, same document is rewrittenYesShow "Not saved", keep the form, offer Try again
Upload photo to submissions/{id}/photo.jpgCloud StorageYes, the same object name is replacedYour choiceShow "Saved, photo missing", offer Retry photo
Write photoPath to the reportFirestoreYes, same valueOnly with a photoRetry; the file is already uploaded
Write submittedAtFirestoreYes, see the server sectionYesShow "Saved, not submitted", offer Submit
Notify the teamServer functionOnly with a claim and idempotency keyYesThe server retries; the app shows "pending"

The upload row depends on a fixed path. Google Cloud Storage's objects documentation says replacing a stored object "happens atomically" and that "until the new upload completes, the old version of the object is served to readers." Uploading to the same name twice therefore leaves one file. FlutterFlow's built-in Upload/Save Media action gives you the uploaded file URL but does not document the Firebase path it uses. A retry with that action may add a second file and leave the first one orphaned. That costs storage and is not a duplicate report, but if you want one object per report, select the file with the action's local (widget state) option and upload it yourself:

// Custom action: uploadSubmissionPhoto(submissionId, photo), returns String.
// Check the generated parameter type in your project; FlutterFlow passes
// an uploaded local file as FFUploadedFile.
import 'package:firebase_storage/firebase_storage.dart';

Future<String> uploadSubmissionPhoto(
  String submissionId,
  FFUploadedFile photo,
) async {
  final path = 'submissions/$submissionId/photo.jpg';
  await FirebaseStorage.instance
      .ref(path)
      .putData(photo.bytes!, SettableMetadata(contentType: 'image/jpeg'));
  return path;
}

The photoPath and submittedAt writes can stay as FlutterFlow's built-in Update Document action, because updating a known document reference with the same value is harmless.

Resume after a restart instead of starting over

A retry can only resume if the app remembers which submission it was working on. Page state and widget state reset when the app restarts. FlutterFlow's App State documentation describes the Persisted option as saving the variable to disk "so that it can be loaded when the app is restarted."

Close-up of a dark cloth-bound notebook lying closed on a black desk, with a single frayed copper ribbon bookmark hanging from the page edges to mark one place in the book.

The bookmark in that picture is the idea: the app keeps a marker for where the submission stopped, so the next attempt opens at that page instead of the first one. Two persisted App State variables are enough:

  • pendingSubmissionId (String): empty when no submission is in progress.
  • pendingStep (Integer): how many steps have finished, from 0 to 4.

The Submit button's action flow then reads as a series of conditions:

  1. If pendingSubmissionId is empty, call newSubmissionId, store the result and set pendingStep to 0.
  2. If pendingStep is below 1, call saveSubmission, then set pendingStep to 1.
  3. If pendingStep is below 2, call uploadSubmissionPhoto with the selected photo, then set pendingStep to 2.
  4. If pendingStep is below 3, update the report's photoPath to submissions/<id>/photo.jpg, built from pendingSubmissionId, then set pendingStep to 3. Building the path from the ID rather than from the upload action's output means this step still works after a restart.
  5. If pendingStep is below 4, update submittedAt with the current time, then set pendingStep to 4.
  6. Clear pendingSubmissionId, reset pendingStep and show the submitted message.

If your form allows a report without a photo, skip steps 3 and 4 when none is selected and set pendingStep to 3 directly.

The step counter is written after each step succeeds. If the app is killed between a successful step and the counter update, that step runs again on the next attempt, which is why every step above has to be safe to repeat. The counter only saves work; the idempotent steps are what prevent duplicates.

One limit: the selected photo lives in widget state and does not survive a restart. When the app reopens with pendingStep below 2, ask the user to pick the photo again. The report text is already in Firestore, so they do not retype it.

Show the message that matches the saved state

After a partial failure, the honest message says what was saved and what is still missing. "Something went wrong" invites a full retry. "Submitted" hides a missing photo.

State diagram for one field report document. Saved leads to photo attached, which leads to submitted and then notified. Failure branches show that a failed upload stays in saved with a photo retry, and a notification that keeps failing ends in a notify error state that the server records.

The status comes from fields on the document rather than one overwritten enum, so a late retry cannot move a report backwards. A report with submittedAt and no notifiedAt is submitted, whatever else the client writes afterwards.

Fields presentStatusMessage to show
No documentNot saved"Report not saved. Check your connection and try again."
Document, no photoPathSaved"Report saved. The photo did not upload." with Retry photo
photoPath, no submittedAtPhoto attached"Report saved with photo. Tap Submit to send it for review."
submittedAt, no notifiedAtSubmitted"Report submitted. The team will be notified."
notifiedAtNotified"Team notified."
notifyErrorNeeds attention"Report submitted. Notifying the team failed; it is listed for review."

Check the rows from the bottom up and use the first one that matches, so a report submitted without a photo shows as submitted. Only show the last two rows when the app reads those fields from the document. The app never writes notifiedAt itself, so it cannot claim the team was told before the server has done it. Before you promise "listed for review", make sure someone actually reviews reports in that state.

Move the team notification to the server

FlutterFlow's Send Email utility action "opens an email app with a prefilled recipient, subject, and message," and the user must send it. The app cannot confirm that the email left the device, so a "Team notified" message after it is a guess.

Send the notification from a Cloud Function that runs when the report becomes submitted. Firebase's retry documentation explains why the default is not enough: "if a function invocation terminates with an error, the function is not invoked again and the event is dropped." With retry: true, 2nd gen functions retry for up to 24 hours, execution is at least once, and the docs say retried functions "must be idempotent." The Firestore triggers guide adds that a single event "may result in multiple function invocations" and warns that writing to the document that triggered the function can cause an infinite loop.

The function below handles those three points. It acts only on the transition into submitted, claims the work in a transaction with a short lease, and passes a key derived from the report ID to the provider:

import { onDocumentWritten } from "firebase-functions/v2/firestore";
import { initializeApp } from "firebase-admin/app";
import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";

initializeApp();
const db = getFirestore();
const LEASE_MS = 2 * 60 * 1000;
const GIVE_UP_MS = 60 * 60 * 1000;

// Placeholder: replace with your email or chat provider call.
// Pass the key to the provider if it supports idempotency keys.
declare function notifyTeam(submissionId: string, idempotencyKey: string): Promise<void>;

export const notifyOnSubmit = onDocumentWritten(
  { document: "submissions/{submissionId}", retry: true },
  async (event) => {
    const before = event.data?.before.data();
    const after = event.data?.after.data();
    // Act only on the transition into "submitted". Later writes, including
    // this function's own updates, are ignored.
    if (before?.submittedAt || !after?.submittedAt) return;

    const ref = db.doc(`submissions/${event.params.submissionId}`);

    if (Date.now() - Date.parse(event.time) > GIVE_UP_MS) {
      await ref.update({ notifyError: "gave-up-after-retries" });
      return; // end condition, so a permanent failure does not retry for 24 hours
    }

    const claimed = await db.runTransaction(async (tx) => {
      const snap = await tx.get(ref);
      const data = snap.data();
      if (!data || data.notifiedAt) return false;
      const until = data.notifyClaimUntil as Timestamp | undefined;
      if (until && until.toMillis() > Date.now()) {
        throw new Error("another invocation holds the claim; retry later");
      }
      tx.update(ref, {
        notifyClaimUntil: Timestamp.fromMillis(Date.now() + LEASE_MS),
      });
      return true;
    });
    if (!claimed) return;

    // Throwing here lets Cloud Functions retry the event.
    await notifyTeam(ref.id, `submission-${ref.id}`);
    await ref.update({ notifiedAt: FieldValue.serverTimestamp() });
  },
);

Firebase recommends the event ID as the idempotency key for external APIs. Here the key is the report ID instead, because the business rule is one notification per report, and a later submittedAt rewrite would arrive as a different event. If two invocations of the same event overlap, the second one finds an active claim and throws, so Cloud Functions retries it after the lease would have expired. The one-hour give-up follows the docs' advice to set an end condition; choose a window that fits how long your provider can be down.

One gap remains. If the function crashes after the provider accepts the message and before notifiedAt is written, the retry sends again. A provider that honors idempotency keys drops that copy. One that does not will deliver it twice. If you need stronger handling than that, the webhook reliability guide covers deduplication on the receiving side.

Why deleting the document is not a safe rollback

Deleting the report when the upload fails looks like a clean undo, but it often makes things worse. The delete is a write and can fail for the same network reason the upload did. With offline persistence enabled, Firestore queues writes and syncs them later, so the delete may land long after the user has moved on. If a server trigger already ran on the saved document, deleting it does not recall what the trigger did. And the user loses text they typed, which is the one part that saved correctly.

Compensation is reasonable for something your own code created and nothing else has read yet, such as removing an orphaned photo that no report points to. That kind of cleanup belongs in a scheduled server job that checks references before deleting, not in a failure branch on the client. For the report itself, keep it, show its real status, and let the user either finish it or discard it on purpose.

Test each failure on purpose

The acceptance check for this design is: fail each step separately, retry after an app restart, and confirm there is exactly one report, at most one photo object and no message that claims more than the document shows. Run it on a test project with synthetic data:

  1. Fail the save. Turn on airplane mode before tapping Submit, or deny writes to submissions in your test rules. Expect "Report not saved", then restore access and retry. One document exists.
  2. Fail the upload. Deny writes to submissions/{id}/ in your Storage rules. Expect "Report saved. The photo did not upload." Restore the rule, tap Retry photo, and confirm one object at the fixed path.
  3. Kill the app between steps. Add a temporary Debug Log before the photoPath update, force quit after it prints, reopen and submit. Expect the flow to continue with the photoPath update for the same pendingSubmissionId, without asking for the photo again.
  4. Fail the notification. Make notifyTeam throw once. Expect "Report submitted. The team will be notified." until the retry succeeds and notifiedAt appears.
  5. Duplicate the trigger. In the emulator, run the function twice for the same event. Expect one provider call and one notifiedAt.
  6. Check the counts. Query submissions for your test title and list the report's Storage folder. One document, one photo.

The simulation behind this article ran 100 combinations of those failures, crash points, duplicate events and a failed first provider call. Every combination ended with one report, one stored photo and one logical notification, and no run showed "Team notified" before the server wrote notifiedAt. That checks the protocol's logic only. Firestore, Storage, FlutterFlow's generated code and your provider can each behave differently, which is why the list above runs against real services.

What this design does not cover

  • Offline-first submission. Firestore can queue the set() while offline, but this flow treats offline as "not saved yet". Supporting offline submission needs its own handling of when a write counts as committed.
  • Multiple photos. Give each one a fixed path, such as photos/0.jpg, and track them as a list. The single-photo version keeps the example short.
  • When to drop into code. If more of the flow moves into custom actions, the guide on when to leave FlutterFlow custom code for pure Flutter helps decide where that line is. For a widget-based upload picker, see what a FlutterFlow custom widget must get right.
  • Form validation. Run it before step 1 so a submission only starts with valid input; the FlutterFlow form state guide covers that part.

Pick the submit flow in your app that touches the most systems and write down, for each action, where it commits and what happens if it runs twice. Any row where the answer is "a second copy" is the first one to fix.

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

Does FlutterFlow roll back earlier actions when a later action fails?

No, synchronous actions in a FlutterFlow action flow run in order, and an earlier write stays committed when a later step fails. The Action Flow Editor documentation describes each synchronous action waiting for the previous one to complete, which is ordering rather than rollback. It does not describe runtime error handling, so test what your own build does when an action throws. Design the flow so a retry can safely continue from the step that failed instead of relying on an undo.

Can Batch Firestore Writes include a file upload or an email?

No, a batch only covers Firestore document writes, so an upload to Cloud Storage or a message sent through an email provider cannot be part of it. FlutterFlow's Firestore actions page describes the batch as all operations succeeding or none being applied. Use it when several documents must change together. For steps in other systems, make each one safe to repeat and record its progress on the document.

How do I stop a retried submit from creating a duplicate document?

Generate the document ID once, store it in a persisted App State variable, and write with that ID on every attempt. Calling doc() on a collection creates an ID locally without writing anything, as Firestore's add data guide shows. Writing with set() and merge: true then rewrites the same document instead of adding a new one. Clear the stored ID only after the whole submission has finished.

Can the FlutterFlow Send Email action notify my team reliably?

No, the Send Email utility action opens an email app with a prefilled message, and the user still has to send it. FlutterFlow's utility actions documentation states this directly, so the app cannot confirm that anything was sent. For a notification your team depends on, trigger a Cloud Function when the document becomes submitted, enable retries, and let the function record notifiedAt after the provider accepts the message.