durable_sync_queue
A persistent, ordered, retrying operation queue for offline-first Dart and Flutter apps.
Write to your local database, enqueue the server call, and let the queue deliver it. Delivery is in order, with backoff when the network misbehaves, and safe to retry because every operation carries a stable idempotency key.
final queue = SyncQueue(
store: myStore, // your database, see below
handler: (op) async {
final response = await dio.post(
'/orders',
data: op.payload,
options: Options(headers: {'Idempotency-Key': op.idempotencyKey}),
);
return const Outcome.delivered();
},
onError: (e) => e is DioException && (e.response?.statusCode ?? 500) < 500
? Outcome.rejected('${e.response?.statusCode}')
: Outcome.retryLater('$e'),
)..start();
await queue.enqueue('create_order', order.toJson(), group: 'order:${order.id}');
Why
"Save offline, sync later" is easy to demo and hard to get right. This package handles the parts that cause real production bugs:
| Problem | What the queue does |
|---|---|
| The response is lost after the server committed, so a retry duplicates the write | A stable idempotency key per operation, reused on every attempt |
| An "update" reaches the server before its "create" | Strict ordering within a group; groups are independent, so one stuck entity doesn't block the rest |
| Thousands of clients retry at the same second after an outage | Exponential backoff with jitter, plus support for server Retry-After |
| One bad payload blocks the queue forever | Permanent rejections are dead-lettered and the group moves on; transient failures give up after maxAttempts |
| The user deletes something that may or may not have reached the server | cancel() only succeeds if the operation was never attempted, because the attempt counter is persisted before sending |
| Retries wait out their backoff even after the connection is back | drain(force: true) on reconnect skips backoff but keeps order |
Install
dart pub add durable_sync_queue
Concepts
Groups. Every operation belongs to a group (default: 'default').
Operations in a group are delivered strictly in insertion order. A transient
failure pauses only that group. Use the id of the entity being changed, for
example group: 'invoice:42'.
Outcomes. Your handler returns one of these:
| Outcome | Use for | Effect |
|---|---|---|
Outcome.delivered() |
2xx | removed from the queue |
Outcome.rejected(reason) |
4xx validation errors | dead-lettered; the group continues |
Outcome.retryLater(reason, after: …) |
timeouts, 5xx, 429, offline | the group pauses until the backoff (or after) elapses |
Exceptions thrown by the handler go through onError. By default they are
treated as retryLater.
Draining. start() drains immediately, after every enqueue, and
whenever a retry falls due. Call drain(force: true) when connectivity
returns. Concurrent drains share a single pass.
Inspecting. pending(), deadLetters(), watchPendingCount() (for a
"3 changes waiting to sync" badge), and the events stream.
Storage
Implement QueueStore (three methods) on top of the database you already
use. Ideally, write the queue entry in the same transaction as the local
change, so the two can never diverge.
class DriftQueueStore implements QueueStore {
DriftQueueStore(this.db);
final AppDatabase db;
@override
Future<List<QueuedOperation>> loadAll() async =>
(await db.select(db.queue).get()).map((r) => QueuedOperation.fromJson(jsonDecode(r.json))).toList();
@override
Future<void> save(QueuedOperation op) =>
db.into(db.queue).insertOnConflictUpdate(QueueCompanion.insert(id: op.id, json: jsonEncode(op.toJson())));
@override
Future<void> delete(String id) => (db.delete(db.queue)..where((r) => r.id.equals(id))).go();
}
Calling enqueue inside a database transaction is supported. Automatic
drains run in the zone where the queue was created, so they never inherit
the transaction's zone. This matters for Drift, whose transactions are bound
to a zone.
Included:
InMemoryQueueStore, for tests and prototypes.JsonFileQueueStore(import 'package:durable_sync_queue/io.dart'), which writes atomically (temp file plus rename). It suits small queues ondart:ioplatforms. It is kept out of the main library so the core stays web-compatible.
Server side
Idempotency only works if the server participates. It should store the
response for each Idempotency-Key and return it for repeated requests
without executing them again. This is standard on payment APIs (Stripe,
Adyen) and simple to add elsewhere.
Background
This package was extracted from the sync engine of flutter-fintech-wallet, where these rules are exercised end-to-end against a server that drops responses on purpose.
License
MIT
Libraries
- durable_sync_queue
- A persistent, ordered, retrying operation queue for offline-first apps.
- io
dart:io-only additions: a file-backed JsonFileQueueStore.