DEV Community

Optimistic UI & Offline Payment States in Flutter: Handling Backend Rail Failures on the Client

In my previous article, we explored how backend systems design rail-agnostic payment engines to survive infrastructure outages like the Wise US trust bank denial.

But backend redundancy is only half the battle.

What happens on the client screen when a payment rail stutters, times out, or triggers a dynamic fallback? If your Flutter app relies on spinning loaders and rigid await calls for every network request, a dropped banking API creates a jarring user experience—or worse, duplicate charges.

As product engineers, we must design mobile state architectures that remain calm when backend rails stall. Here is how to implement Optimistic UI updates, offline transaction queues, and graceful state rollbacks in Flutter using Riverpod.

The Core Problem: The Synchronous UX Trap

Traditional mobile payment flows usually look like this:

  1. User taps “Pay $50”.

  2. Show a full-screen CircularProgressIndicator.

  3. Wait 3–8 seconds for the backend to hit the payment gateway.

  4. If the server times out or fails over to a secondary rail, the UI hangs or throws a generic red snackbar.

This approach fails mobile users on poor connections or during server-side rail pivots.

Instead, a production-grade fintech app should adopt an Optimistic First mental model:

1. Modeling Optimistic State with Riverpod

To prevent UI flashes and handle asynchronous reconciliation, your state object needs to represent local confidence separate from backend confirmation.

Here is a clean state model using Notifier in Riverpod:

enum PaymentStatus { pending, settled, failed }
class TransactionState {
  final String id;
  final double amount;
  final PaymentStatus status;
  final bool isOptimistic;
  final String? errorMessage;

  TransactionState({
    required this.id,
    required this.amount,
    required this.status,
    this.isOptimistic = false,
    this.errorMessage,
  });

  TransactionState copyWith({
    PaymentStatus? status,
    bool? isOptimistic,
    String? errorMessage,
  }) {
    return TransactionState(
      id: this.id,
      amount: this.amount,
      status: status ?? this.status,
      isOptimistic: isOptimistic ?? this.isOptimistic,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

2. Implementing Optimistic Mutation & Rollback

When the user triggers a payment, we immediately inject the new transaction into our local state with isOptimistic = true and update the balance instantly.

If the backend fails — even after retrying or pivoting rails — we execute an atomic rollback to restore the user’s previous state and inform them without breaking the UI context.

@riverpod
class PaymentNotifier extends _$PaymentNotifier {
  @override
  List<TransactionState> build() => [];

  Future<void> submitPayment({required String id, required double amount}) async {
    // 1. Snapshot previous state for rollback safety
    final previousState = state;

    // 2. Optimistic local update (Instant feedback)
    final optimisticTx = TransactionState(
      id: id,
      amount: amount,
      status: PaymentStatus.pending,
      isOptimistic: true,
    );

    state = [optimisticTx, ...state];

    try {
      // 3. Dispatch to backend API
      final response = await ref.read(paymentRepositoryProvider).executeTransfer(
        id: id,
        amount: amount,
      );

      // 4. On Success: Reconcile with actual server response
      state = state.map((tx) {
        if (tx.id == id) {
          return tx.copyWith(
            status: PaymentStatus.settled,
            isOptimistic: false,
          );
        }
        return tx;
      }).toList();

    } catch (e) {
      // 5. On Failure: Rollback optimistic state gracefully
      state = previousState;

      // Notify UI via side-effect / event channel
      ref.read(notificationStreamProvider).add("Payment failed: ${e.toString()}");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Offline Persistence: Local Caching with Hive or Isar

What if the device loses connection right when the user submits a payout?

Rather than failing immediately, resilient mobile apps store pending transactions locally in an offline persistent storage engine (such as Hive or Isar) before attempting network transmission.

  1. Write Local: Commit the transaction payload to local device storage with an idempotencyKey (UUID v4).

  2. Background Sync: Use a connectivity listener or background worker to drain the offline queue once network availability returns.

  3. Idempotency Guarantee: Because the client generates the idempotencyKey upfront, retrying a queued offline payment will never double-charge the user, even if the backend rail experienced a temporary timeout.

Key Engineering Takeaways for Flutter Developers

Handling backend rail volatility on the client comes down to three principles:

  • Instant UI Feedback: Mutate local state immediately. Don’t make the user wait for external bank settlement confirmation to see visual progress.

  • Always Keep an Undo Route: Never mutate state irreversibly without holding a snapshot to restore if the network call fails.

  • Idempotency is Client-Driven: Generate unique keys on the mobile device before sending the request. This allows safe retries across flaky networks without risking duplicate charges.

When you pair a rail-agnostic backend with an optimistic, resilient mobile client, your app feels lightning-fast and rock-solid — regardless of what happens behind the API gateway.

Top comments (0)