Offline & ResilienceSep 6, 2024 · 2 min read

A Queue That Tags Every Offline Action With a Client-Generated Idempotency Key

An offline action queue that retries blindly will eventually double-submit a transfer. The fix isn't smarter retry logic — it's making every queued action safe to replay.


An offline queue's job sounds simple: hold an action while there's no connection, send it when there is one. The failure mode shows up later, and it's rarely "the queue lost an action" — it's the opposite. A request goes out, the response never comes back (timeout, app killed mid-flight, connection drops right as the server replies), and the queue — reasonably, from its own point of view — retries. If the original request actually succeeded server-side, that retry is a second transfer.

Why "just don't retry on ambiguous failures" doesn't work #

The tempting fix is to only retry on errors you're sure are safe — a clear 4xx, a connection-refused before anything was sent. But the whole reason this queue exists is to handle the ambiguous case: the request that might have gone through. Refusing to retry those defeats the point of having an offline queue at all; you'd just be building a fragile "sometimes it works" experience instead.

The fix: make every action idempotent at the source #

Generate the idempotency key when the action is queued, not when it's sent:

offline_action.dart
class OfflineAction {
  OfflineAction({
    required this.type,
    required this.payload,
    String? idempotencyKey,
  }) : idempotencyKey = idempotencyKey ?? const Uuid().v4();
 
  final String type;
  final Map<String, dynamic> payload;
  final String idempotencyKey; // generated once, reused on every retry
  int attemptCount = 0;
}

The key comes into existence exactly once, at creation time, and travels with the action through every retry. The backend's job is to treat a repeated key as a no-op: "I already processed this — here's the original result," not "here's a new transaction." That contract has to exist on the server side for this to work at all; the client-side key is useless without a backend that honors it.

offline_queue.dart
Future<void> _sendAction(OfflineAction action) async {
  try {
    await api.submit(
      type: action.type,
      payload: action.payload,
      idempotencyKey: action.idempotencyKey, // same key, every attempt
    );
    _queue.remove(action);
  } catch (_) {
    action.attemptCount++;
    if (action.attemptCount >= _maxAttempts) {
      _moveToDeadLetter(action);
    }
    // otherwise: leave it queued, retry on next reconnect
  }
}

Worth noting

Notice what's deliberately absent: no logic here trying to guess whether the previous attempt "probably" succeeded. That guess is exactly what causes double-submits. The idempotency key makes the guess unnecessary — the server can answer definitively, so the client doesn't have to.

The one thing this doesn't fix #

An idempotency key stops the same queued action from executing twice. It does nothing for two genuinely different actions that happen to conflict — two devices both queuing a transfer that, combined, overdraws an account. That's a separate problem (conflict resolution, not idempotency), and reaching for this pattern to solve it is a common way to end up with a queue that looks safe and isn't.

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.