A Flutter offline sync queue on Hive CE, gaps included
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;
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));
}
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();
}
The backoff is Duration(seconds: 1 << retryCount).
Walking it through:
| Attempt | On failure | Next attempt allowed |
|---|---|---|
| 1 | retryCount 1 → pending | 2 s later |
| 2 | retryCount 2 → pending | 4 s later |
| 3 | retryCount 3 → failed | only 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_queueis the only Hive box. The offline-first repository in the README is an example, not code inlib/. - 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
POSTlands 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-dayclearStale()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;processQueueand 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 |
The queue, its connectivity check and the cubit are in
lib/core/offline
on GitHub.