Skip to content
D
Journal
Flutter · 18 August 2026 · 14 min read

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.

4
Dart files for the whole pattern
8
max attempts with exponential back-off
5 min
cap on retry interval

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

Library2026 statusRecommendation
Drift (formerly Moor)Active, regular releasesCorrect default — type-safe, reactive, isolate threading
sqfliteActive, low-level SQLite wrapperIf you want raw SQL without an ORM
ObjectBoxActive, paid proprietary syncIf you need native multi-device sync
IsarAbandoned by authorMigrate, no new projects
HiveAbandoned by authorSame
RealmSync killed by MongoDB in 2024Migrate, sync-less it loses its main appeal

For the sync worker, we use two complementary mechanisms:

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.

createOrder

atomic transaction

watch outbox
WHERE pending

POST with retry

200 OK

UPDATE status=synced

reactive stream

UI · Flutter widgets
ref.watch orderSyncStatusProvider

Repository
createOrder / updateNote / ...

SQLite via Drift
orders + outbox

Sync Worker
Riverpod StreamProvider

Server API
idempotent on client UUID

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

APIWorkerSQLiteRepositoryUserAPIWorkerSQLiteRepositoryUserwatch() emits new entryUI switches ⏱ → ✓ (stream)createOrder(input)1BEGIN TRANSACTION2INSERT order3INSERT outbox (status=pending)4COMMIT5Local Order (instant)6UPDATE outbox SET status=in_flight7POST /orders (idempotencyKey)8200 OK9UPDATE outbox SET status=synced10

Outbox row state machine

insert

worker picks

200 OK

retriable error

attempts >= 8

user retries

abandon

pending

in_flight

synced

failed

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

Anti-pattern
1. Check Connectivity before the call (unreliable — captive hotspot) 2. Tight retry loop without back-off (battery drain + backend DDoS) 3. Payload in SharedPreferences (non-transactional, not concurrent-safe) 4. Block the UI during sync (guaranteed jank in build()) 5. Use Hive or Isar in 2026 (abandoned libraries)
Fix
1. Attempt the call, handle errors — network code is source of truth 2. Exponential back-off + jitter (Marc Brooker, AWS 2015), cap 5 min 3. SQLite via Drift in an atomic transaction 4. Riverpod worker independent from build, isolate if heavy 5. Drift as default, sqflite for raw SQL

Tests — typical pyramid

Test typeTargetTypical volume
Unit — Repositoryatomicity, correct outbox write~10 tests
Unit — Workerstate machine, error handling~15 tests
Unit — nextBackoffprogression, cap, jitter distribution~5 tests
Integrationfull 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:

01-offline-sync
The code shown here, compilable, tested, with README demo GIF.
80%+ test coverage
Unit tests Repository + Worker + nextBackoff, plus integration tests with in-memory backend.
Documentation
README with architecture, decisions, roadmap. Blog post cross-linked to this article.

Main sources

Did this article resonate?

Delano Toungsi

Delano Toungsi

Freelance mobile CTO · Missions, fractional & co-founder · Yaoundé full-remote

Propose a mission →

A mobile need that sounds like yours?