Event-Driven & Real-TimeSep 3, 2024 · 8 min read

Event-Driven Architecture in Flutter: Real-Time Balance Updates Without Polling

Why polling a balance endpoint every few seconds is the wrong default for a financial app, and the event-driven pattern that replaces it — including the sequence-gap recovery logic most tutorials skip.


Every fintech app runs into the same question eventually: how does the number on the screen become the number in the ledger? For a to-do list, a few seconds of staleness is invisible. For a balance someone is staring at while deciding whether they can afford something, it isn't — and the gap between "the backend knows" and "the screen shows it" is exactly where trust in a financial product is won or lost.

Most teams solve this the same way, and it works right up until it doesn't.

The naive approach: poll and hope #

Almost every Flutter fintech app starts here, and it's a reasonable place to start — it works with any REST backend, no infrastructure changes required, and it ships in an afternoon:

balance_poller.dart
Timer.periodic(const Duration(seconds: 4), (_) async {
  final balance = await api.fetchBalance(accountId);
  setState(() => _balance = balance);
});

It looks harmless. It isn't, at fintech scale. Four things go wrong as soon as this leaves a demo and meets real users:

  • Cost scales with open apps, not with money moving. A user who never transacts still fires a request every four seconds for as long as the screen is open — backend load proportional to idle attention, not to actual events.
  • The staleness window is the poll interval, worst case. A transaction that posts one second after a poll won't show up for another three, and there's no way to tighten this without making the first problem worse.
  • Responses can arrive out of order. Two in-flight requests, a slow network, and the second-to-last response can land after the last one — the UI briefly shows an older balance than the one it just displayed.
  • It fails invisibly, not gracefully. A dropped poll just means the UI silently shows stale data until the next tick. Nothing errors, nothing retries with intent — it just quietly lies for a few seconds at a time.

The fix: treat the balance as a stream, not a value you fetch #

The shift is conceptual before it's technical: stop asking "what is the balance right now?" and start asking "what happened since I last knew?" The backend emits an ordered, sequenced stream of domain events per account — BalanceUpdated, TransactionPosted with a pending → settled lifecycle — over a WebSocket. The client fetches the balance exactly once, on cold start, then applies every event after that as a delta. It never asks again.

1. The event contract #

Every event carries a monotonic, per-account sequence number from the server — not a timestamp. Clocks skew and NTP drifts; timestamps can't give an unambiguous total order, but a sequence number lets the client detect exactly one thing that matters: did I miss something?

balance_event.dart
sealed class BalanceEvent {
  const BalanceEvent({
    required this.accountId,
    required this.sequence,
    required this.occurredAt,
  });
 
  final String accountId;
  final int sequence; // monotonic, assigned by the server
  final DateTime occurredAt;
}
 
class BalanceUpdated extends BalanceEvent {
  const BalanceUpdated({
    required super.accountId,
    required super.sequence,
    required super.occurredAt,
    required this.balanceMinorUnits, // int, never a double
  });
 
  final int balanceMinorUnits;
}
 
class TransactionPosted extends BalanceEvent {
  const TransactionPosted({
    required super.accountId,
    required super.sequence,
    required super.occurredAt,
    required this.transactionId,
    required this.amountMinorUnits,
    required this.status, // pending | settled | reversed
  });
 
  final String transactionId;
  final int amountMinorUnits;
  final TransactionStatus status;
}

Storing money as integer minor units instead of double is its own pattern worth reading on its own — see why double will break your fintech app — and it's a prerequisite here, not an optional refinement.

2. A socket that assumes it will be dropped #

On a mobile connection, the socket will drop — backgrounding, a tunnel, a flaky network. The reconnect logic is the main path, not an edge case:

balance_event_socket.dart
class BalanceEventSocket {
  BalanceEventSocket(this._uri);
  final Uri _uri;
 
  WebSocketChannel? _channel;
  StreamSubscription? _sub;
  int _retryCount = 0;
  final _controller = StreamController<BalanceEvent>.broadcast();
 
  Stream<BalanceEvent> get events => _controller.stream;
 
  Future<void> connect() async {
    try {
      _channel = WebSocketChannel.connect(_uri);
      _retryCount = 0; // reset backoff on a clean connect
      _sub = _channel!.stream.listen(
        _handleRaw,
        onDone: _scheduleReconnect,
        onError: (_) => _scheduleReconnect(),
      );
    } catch (_) {
      _scheduleReconnect();
    }
  }
 
  void _handleRaw(dynamic raw) {
    if (_controller.isClosed) return;
    _controller.add(BalanceEventCodec.decode(jsonDecode(raw as String)));
  }
 
  void _scheduleReconnect() {
    _sub?.cancel();
    final attempt = _retryCount++;
    final backoff = Duration(
      seconds: math.min(30, math.pow(2, attempt).toInt()),
      milliseconds: math.Random().nextInt(1000), // jitter
    );
    Future.delayed(backoff, connect);
  }
}

Capped exponential backoff with jitter, not a fixed retry interval — otherwise every client that dropped during a brief server blip reconnects in the same instant, and a hiccup turns into a thundering herd against your own WebSocket gateway.

3. The reconciler: where the correctness actually lives #

This is the piece most tutorials skip, and it's the one that makes the pattern trustworthy rather than merely fast:

balance_store.dart
class BalanceStore extends StateNotifier<BalanceState> {
  BalanceStore(this._socket, this._api) : super(BalanceState.initial()) {
    _socket.events.listen(_apply);
    _socket.connect();
  }
 
  final BalanceEventSocket _socket;
  final AccountApi _api;
 
  Future<void> _apply(BalanceEvent event) async {
    final expected = state.lastSequence + 1;
 
    if (event.sequence < expected) return; // duplicate replay — drop it
 
    if (event.sequence > expected) {
      // Gap: something was missed. Don't guess — go get the truth.
      final fresh = await _api.fetchBalance(event.accountId);
      state = state.copyWith(
        balanceMinorUnits: fresh.balanceMinorUnits,
        lastSequence: fresh.sequence,
        pendingTransactions: const [],
      );
      return;
    }
 
    state = switch (event) {
      BalanceUpdated(:final balanceMinorUnits) => state.copyWith(
          balanceMinorUnits: balanceMinorUnits,
          lastSequence: event.sequence,
        ),
      TransactionPosted(status: TransactionStatus.pending) => state.copyWith(
          pendingTransactions: [...state.pendingTransactions, event],
          lastSequence: event.sequence,
        ),
      TransactionPosted(status: TransactionStatus.settled) => state.copyWith(
          pendingTransactions: state.pendingTransactions
              .where((t) => t.transactionId != (event as TransactionPosted).transactionId)
              .toList(),
          lastSequence: event.sequence,
        ),
      _ => state,
    };
  }
}

Three rules, in order: a sequence behind what's expected is a duplicate and gets dropped; a sequence ahead means something was missed and triggers a REST reconciliation call rather than a guess; only an exact match gets applied as a delta. That middle branch is what turns "the socket dropped for six seconds" from a bug report into a non-event.

4. What the widget actually does #

balance_display.dart
class BalanceDisplay extends ConsumerWidget {
  const BalanceDisplay({required this.accountId, super.key});
  final String accountId;
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(balanceProvider(accountId));
 
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        AnimatedSwitcher(
          duration: const Duration(milliseconds: 200),
          child: Text(
            formatMinorUnits(state.balanceMinorUnits),
            key: ValueKey(state.balanceMinorUnits),
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
        if (state.pendingTransactions.isNotEmpty)
          Text('${state.pendingTransactions.length} pending',
              style: Theme.of(context).textTheme.bodySmall),
      ],
    );
  }
}

Notice what's not here: no FutureBuilder, no manual refresh button, no loading spinner on every rebuild. The widget just watches state that arrives — the complexity lives in the reconciler, not scattered across every screen that shows a balance. Riverpod is shown here because it's what we default to for stream-backed state; the same reconciler logic drops into a Bloc's emit calls just as cleanly if that's your team's convention.

What this costs you #

None of this is free, and pretending otherwise is exactly what makes agency blog posts about Flutter feel hollow.

  • More moving parts. A single GET request became a socket, a reconnect policy, and a reconciler with three branches — real code to write, own, and hand off.
  • Eventual consistency doesn't disappear, it shrinks. There's still a bounded window between "the ledger knows" and "the screen shows it." This pattern takes that window from poll-interval seconds down to sub-second, and makes gaps self-healing instead of requiring a manual refresh — it doesn't make the window zero.
  • Testing gets harder, not optional. You have to test out-of-order delivery, duplicate delivery, and mid-stream disconnects on purpose. Ship a fake event socket that can be scripted to reorder, duplicate, and drop events from day one of the test suite.
  • It only works if the backend can actually promise ordering. This assumes at-least-once delivery with a real monotonic sequence per account. If your backend can't guarantee that yet, client-side sophistication here just hides backend bugs behind "temporary glitches" instead of fixing them.
  • Cold start still needs one REST call. The socket is for deltas after you know where you stand, not for bootstrapping.

What actually matters in an audit #

Compliance note

The client-side event stream is a UX optimization. It is never the system of record. Any dispute, chargeback, or regulator inquiry gets resolved against the backend's transaction ledger — not against what a Flutter widget happened to render at some point.

Two things follow from that:

  • Never let a client-observed event alone authorize a state-changing action. If a settled event unlocks something like a "send" button, confirm that state against a fresh authoritative check server-side before the action executes.
  • Log sequence gaps on the server side too, not just the client's silent recovery. A client that frequently detects gaps is often the earliest signal of a delivery bug in your event infrastructure — and during an audit, "we have telemetry on every gap for six months" is a far better position than "the client just quietly re-fetched and we never knew."

TL;DR #

  • Poll-based balance updates cost you battery, backend load, and a staleness window you can't shrink without making the other two worse.
  • Fetch once on cold start, then apply an ordered, sequenced event stream as deltas — never ask "what's the balance" again after that.
  • Use a monotonic per-account sequence number, not timestamps, to detect duplicates and gaps.
  • A detected gap should trigger a REST reconciliation call, not a shrug — that's what makes the pattern self-healing.
  • Reconnect with capped exponential backoff plus jitter, or a server blip becomes a thundering herd.
  • The event stream is UX, not the ledger. Regulators and disputes care about the backend's record, not the client's.

Stuck on this in your own app?

This is the kind of problem I help fintech teams get right the first time — see how App Architecture & Consulting engagements work, or just tell me what you're building.