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