karaboga.dev Notes

A Flutter offline sync queue on Hive CE, gaps included

Cihat Karaboğa · · Flutter

My Flutter boilerplate ships a queue for writes made while offline: 973 lines across six files in lib/core/offline/. Nothing in the app calls it yet — queueOperation is defined and never used. So this is a tour of the plumbing: what it guarantees today, and what whoever wires it up still has to decide.

Everything below is read from commit 47bfb7b (24 September 2026). Versions are at the end.

A queued write is one Hive object

Each pending request is a SyncOperation, a HiveObject with type id 2, stored in a single box named sync_queue. It holds the whole request, not a diff:

@HiveType(typeId: 2)
class SyncOperation extends HiveObject {
  final String id;                         // Uuid().v4()
  final SyncOperationType operationType;   // create · update · delete
  final String entityType;
  final String entityId;
  final String payload;                    // jsonEncode(data)
  final String endpoint;
  final DateTime createdAt;
  int retryCount;
  SyncStatus status;                       // pending · inProgress · completed · failed
  String? errorMessage;
  DateTime? lastAttemptAt;
  // …
}

Trimmed from sync_operation.dart; the comments are mine.

Storing the endpoint and an encoded payload keeps the queue ignorant of the domain: it never imports a feature, it just replays HTTP calls. The cost shows up later — with no entity model, it can't merge two updates to the same record.

“Online” means a DNS answer, not a Wi-Fi icon

ConnectivityService listens to connectivity_plus, but a network interface isn't treated as internet. It confirms with a lookup:

final result = await InternetAddress.lookup(
  'google.com',
).timeout(const Duration(seconds: 5));
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;

connectivity_service.dart

When the status flips to online and auto-sync is on (the default), OfflineManager calls processQueue(). Two consequences: the check uses dart:io, so it doesn't run on Flutter web; and until the first check finishes, the status starts as online.

Flushing: oldest first, one at a time

List<SyncOperation> get pendingOperations {
  final now = DateTime.now();
  return _box.values
      .where(
        (op) => op.status == SyncStatus.pending && _isReadyForRetry(op, now),
      )
      .toList()
    ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}

sync_queue.dart

processQueue() walks that list in a plain for loop and maps each operation to POST, PUT or DELETE. An _isProcessing flag stops a second run from starting while one is in flight, so a flapping connection can't send the same operation twice in parallel.

The requests go through the app's shared DioClient, which matters more than it looks: queued writes pass through the same token-refresh interceptor as live ones. If several queued requests hit a 401 together, they share one refresh call (_refreshInFlight ??=) and each is retried once with the new token.

Retries: 2 seconds, 4 seconds, then failed

Future<void> markFailed(String error, {int maxRetries = 3}) async {
  retryCount++;
  errorMessage = error;
  lastAttemptAt = DateTime.now();

  if (retryCount >= maxRetries) {
    status = SyncStatus.failed;
  } else {
    status = SyncStatus.pending;
  }
  await save();
}

sync_operation.dart

The backoff is Duration(seconds: 1 << retryCount). Walking it through:

AttemptOn failureNext attempt allowed
1retryCount 1 → pending2 s later
2retryCount 2 → pending4 s later
3retryCount 3 → failedonly by hand

The code comment promises “2s, 4s, 8s”; the 8-second step never happens, because the third failure already marks the operation failed. And “allowed” is the right word: nothing schedules the retry. The backoff is only checked when a run starts — on the next switch to online, a manual sync() from ConnectivityCubit, or retryFailed(), which resets the count to zero.

Two failure paths are covered well. If the app is killed mid-sync, OfflineManager.init resets any inProgress operation to pending on the next launch. And on logout or session expiry, pending operations are dropped so they aren't sent with the next user's token.

What it doesn't handle

  • No caller. No repository queues a write yet, and there are no local entity boxes — sync_queue is the only Hive box. The offline-first repository in the README is an example, not code in lib/.
  • No conflict resolution. No version or ETag is sent, and a 409 is handled like any other error: retried, then marked failed. Merging is left to the server.
  • No idempotency key. The operation's UUID is never sent. If a POST lands but the response is lost, the retry creates the record twice.
  • Ordering is best-effort. A failed operation doesn't block the ones after it, so an update can reach the server before the create it depends on.
  • Housekeeping is manual. Completed operations stay in the box; clearCompleted() and the 7-day clearStale() exist, but nothing calls them on its own. Logout clears pending operations, not failed ones.
  • Plain storage. The box isn't encrypted, and payloads are stored as JSON strings.
  • Thin tests. The queue tests cover markFailed, staleness and payload decoding; processQueue and the backoff timing aren't tested.

None of these are hard to add — an Idempotency-Key header from the operation id, blocking later operations for the same entityId, a timer for due retries. They're the decisions a real backend forces, which is why they belong to the app that wires the queue up rather than to a template.

Versions

Dart SDK^3.9.0
Flutter>=3.35.0
hive_ce^2.20.0
dio^5.11.1
connectivity_plus^7.3.1
flutter_bloc^9.1.1