Build an offline sync queue in Flutter
The day 2 outbox pattern in concrete Dart: 4 files, 80 lines, one atomic transaction. Drift + Riverpod + WorkManager + Marc Brooker jitter.
Day 2 laid out the 3 mobile strategies against network loss: block, save aside, or confirm immediately (Delano Toungsi — Mobile connection loss: the 3 possible strategies). The operational conclusion is that saving aside is the correct default for most apps. Today we implement it in concrete Flutter.
The result fits in 4 Dart files, ~80 lines of business logic (tests will be three times more, and that’s normal — see §Tests). The architecture is a layer addition, not a rewrite: it slots into an existing Flutter app without breaking the current Repository or state management.
Technical choices in 2026
The Flutter local database landscape has considerably shrunk since 2023. Three popular libraries have been abandoned or degraded (Greenrobot — Flutter databases overview, 2025).
| Library | 2026 status | Recommendation |
|---|---|---|
| Drift (formerly Moor) | Active, regular releases | Correct default — type-safe, reactive, isolate threading |
| sqflite | Active, low-level SQLite wrapper | If you want raw SQL without an ORM |
| ObjectBox | Active, paid proprietary sync | If you need native multi-device sync |
| Isar | Abandoned by author | Migrate, no new projects |
| Hive | Abandoned by author | Same |
| Realm | Sync killed by MongoDB in 2024 | Migrate, sync-less it loses its main appeal |
For the sync worker, we use two complementary mechanisms:
- App open — a Riverpod
StreamProviderthat simultaneously watches the Drift outbox stream and the connectivity stream (Riverpod docs — StreamProvider) - App closed —
flutter_workmanager, a wrapper around Android WorkManager and iOSBGAppRefreshTask(flutter_workmanager on pub.dev)
WorkManager is optional: if your app is opened several times a day (Next Sales, WhatsApp), the foreground worker is enough.
The 3-layer architecture
Cardinal principle: the screen knows nothing about the network, the worker knows nothing about the UI. The Repository bridges the two, but only via the local DB.
The decoupling gives three concrete benefits: (1) test the Repository without an HTTP mock, (2) test the Worker without mounting UI, (3) swap Riverpod for something else (Bloc, GetX) without touching the Repository or the Worker.
File 1 — The SQLite outbox schema
One outbox row = one business action pending server delivery. The pattern comes from micro-services architecture, formalized by Chris Richardson (Chris Richardson — Transactional Outbox Pattern (microservices.io)), and applies fully to mobile. The key to everything is the client-generated id UUID.
// lib/data/local/tables/outbox_entries.dart
@DataClassName('OutboxEntry')
class OutboxEntries extends Table {
TextColumn get id => text()(); // UUID v4 client-generated
TextColumn get aggregateType => text()(); // 'Order', 'Message', 'Note'
TextColumn get aggregateId => text()(); // business entity id
TextColumn get operation => text()(); // 'CREATE', 'UPDATE', 'DELETE'
TextColumn get payload => text()(); // HTTP body JSON
IntColumn get createdAt => integer()();
IntColumn get attempts => integer().withDefault(const Constant(0))();
IntColumn get lastAttemptAt => integer().nullable()();
TextColumn get status => text().withDefault(const Constant('pending'))();
// 'pending' | 'in_flight' | 'synced' | 'failed'
@override
Set<Column> get primaryKey => {id};
}
File 2 — The Repository (the golden rule)
One atomic transaction. If insert(order) succeeds and insert(outbox) fails (or vice versa), we’re in an inconsistent state — the user sees their data but it’s never sent, or vice versa. SQLite via Drift guarantees atomicity:
// lib/data/repositories/orders_repository.dart
class OrdersRepository {
final AppDatabase db;
final Uuid uuid;
OrdersRepository(this.db, this.uuid);
Future<Order> createOrder(OrderInput input) async {
final orderId = uuid.v4();
final outboxId = uuid.v4(); // STABLE id, reused across retries
final order = Order(
id: orderId,
customerId: input.customerId,
items: input.items,
total: input.total,
status: OrderStatus.pending,
createdAt: DateTime.now(),
);
// ⚠️ Both inserts, or none.
await db.transaction(() async {
await db.into(db.orders).insert(order.toCompanion());
await db.into(db.outboxEntries).insert(OutboxEntry(
id: outboxId,
aggregateType: 'Order',
aggregateId: orderId,
operation: 'CREATE',
payload: jsonEncode(order.toJson()),
createdAt: DateTime.now().millisecondsSinceEpoch,
attempts: 0,
lastAttemptAt: null,
status: 'pending',
).toCompanion());
});
return order;
}
}
What we don’t do here: no HTTP call, no connectivity check, no worker notification. The Repository doesn’t know about the network. The worker handles it by watching the Drift stream.
File 3 — The Sync Worker
Full sequence, from tap to server confirmation
Outbox row state machine
The worker code
// lib/data/sync/outbox_worker.dart
class OutboxWorker {
final AppDatabase db;
final ApiClient api;
static const _maxAttempts = 8;
Future<void> processEntry(OutboxEntry entry) async {
// 1. Mark in_flight (prevents another worker from picking it up)
await db.update(db.outboxEntries).replace(
entry.copyWith(status: 'in_flight', lastAttemptAt: _now()),
);
try {
// 2. Send — API must be idempotent on entry.id
await api.executeAction(
idempotencyKey: entry.id,
type: entry.aggregateType,
operation: entry.operation,
payload: jsonDecode(entry.payload),
);
// 3. Mark synced
await db.update(db.outboxEntries).replace(
entry.copyWith(status: 'synced'),
);
} on RetriableException catch (_) {
// 4a. 5xx, timeout, DNS → back to pending for retry
final newAttempts = entry.attempts + 1;
final newStatus = newAttempts >= _maxAttempts ? 'failed' : 'pending';
await db.update(db.outboxEntries).replace(
entry.copyWith(status: newStatus, attempts: newAttempts),
);
} on FatalException catch (_) {
// 4b. 400 validation, 401 auth → failed directly, no retry
await db.update(db.outboxEntries).replace(
entry.copyWith(status: 'failed'),
);
}
}
}
Exponential back-off with jitter (Marc Brooker, AWS 2015)
The canonical reference for mobile retry is Marc Brooker, “Exponential Backoff and Jitter,” AWS Architecture Blog, 2015 (Marc Brooker, AWS Architecture Blog, 2015). Without jitter, multiple clients retrying after the same event form a “thundering herd” that synchronizes their traffic spikes on the backend.
Duration nextBackoff(int attempts) {
const baseMs = 1000; // 1 second
const capMs = 5 * 60 * 1000; // cap at 5 minutes
final exponential = baseMs * math.pow(2, attempts);
final capped = math.min(exponential, capMs).toInt();
// Full jitter: random sleep in [0, capped]
return Duration(milliseconds: math.Random().nextInt(capped));
}
Progression: ~2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s (capped at 5 min). After 8 attempts (~40 min cumulative), the entry becomes failed — beyond that, continuing drains the battery without resolving the issue.
File 4 — The UI (the 4 mandatory UX states)
Recap from day 2: any non-blocking strategy requires displaying 4 UX states — local, pending, synced, failed. The widget reads the status via a StreamProvider.family:
final orderSyncStatusProvider = StreamProvider.family<SyncStatus, String>(
(ref, orderId) async* {
final db = ref.watch(dbProvider);
await for (final entries in db.outboxByAggregate(orderId).watch()) {
if (entries.isEmpty) yield SyncStatus.synced;
else if (entries.any((e) => e.status == 'failed')) yield SyncStatus.failed;
else if (entries.any((e) => e.status == 'in_flight')) yield SyncStatus.inFlight;
else yield SyncStatus.pending;
}
},
);
class SyncStatusIcon extends ConsumerWidget {
final String aggregateId;
const SyncStatusIcon({required this.aggregateId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(orderSyncStatusProvider(aggregateId));
return status.when(
loading: () => Icon(Icons.circle_outlined, size: 14),
error: (_, __) => Icon(Icons.error, color: Colors.orange),
data: (s) => switch (s) {
SyncStatus.synced => Icon(Icons.check_circle, color: Colors.green),
SyncStatus.pending => Icon(Icons.schedule, color: Colors.grey),
SyncStatus.inFlight => Icon(Icons.sync, color: Colors.blue),
SyncStatus.failed => GestureDetector(
onTap: () => _showRetryDialog(context, ref, aggregateId),
child: Icon(Icons.warning, color: Colors.red),
),
},
);
}
}
Five Flutter offline anti-patterns to watch
Tests — typical pyramid
| Test type | Target | Typical volume |
|---|---|---|
| Unit — Repository | atomicity, correct outbox write | ~10 tests |
| Unit — Worker | state machine, error handling | ~15 tests |
| Unit — nextBackoff | progression, cap, jitter distribution | ~5 tests |
| Integration | full flow: createOrder → outbox → worker → synced | ~5 tests |
Critical test cases not to forget: atomic write (rollback if one of the two inserts fails), idempotency (two calls with same id don’t create duplicate), concurrent workers (two processes picking the same entry — only one must proceed via the in_flight status), connectivity return (worker must trigger as soon as connectivity_plus emits connected), app killed during in_flight (on reboot, reset in_flight to pending).
What ships this week
The full Flutter repo lands in day 3.bis (end of week), with:
Main sources
- Brooker, M. — “Exponential Backoff And Jitter”, AWS Architecture Blog, 2015.
- Greenrobot — “Flutter databases overview”, 2025.
- Luci Studio — “The Flutter Local Database Landscape in 2026”.
- Richardson, C. — “Transactional Outbox Pattern”, microservices.io.
- Simon Binder — Drift documentation.
- Riverpod — StreamProvider documentation.
flutter_workmanager— pub.dev.- Stripe — Idempotent Requests API.
- Day 2 — Mobile connection loss: the 3 possible strategies (product context).
Did this article resonate?
Was this article useful?
Click a star to leave a note
Delano Toungsi
Freelance mobile CTO · Missions, fractional & co-founder · Yaoundé full-remote