Aller au contenu
D
Journal
Flutter · 18 août 2026 · 14 min de lecture

Construire une file de sync offline en Flutter

Le pattern outbox du jour 2, en Dart concret : 4 fichiers, 80 lignes, une transaction atomique. Drift + Riverpod + WorkManager + Marc Brooker jitter.

Le jour 2 a posé les 3 stratégies mobile face à la perte de connexion : bloquer, garder de côté, ou confirmer tout de suite (Delano Toungsi — Perte de connexion mobile : les 3 stratégies possibles). La conclusion opérationnelle est que garder de côté est le default correct pour la majorité des apps. Aujourd’hui, on l’implémente en Flutter concret.

Le résultat tient en 4 fichiers Dart, ~80 lignes de logique métier (les tests en font trois fois plus, et c’est normal — voir §Tests). L’architecture est un ajout de couche, pas une refonte : elle s’insère dans une app Flutter existante sans casser le Repository ou le state management en place.

4
fichiers Dart pour tout le pattern
8
tentatives max avec back-off exponentiel
5 min
cap sur l'intervalle de retry

Choix technique en 2026

Le paysage des bases locales Flutter s’est considérablement contracté depuis 2023. Trois bibliothèques populaires ont été abandonnées ou dégradées (Greenrobot — Flutter databases overview, 2025).

BibliothèqueStatut 2026Recommandation
Drift (ex-Moor)Actif, releases régulièresDefault correct — type-safe, réactif, isolate threading
sqfliteActif, wrapper SQLite bas-niveauSi vous voulez du SQL brut sans ORM
ObjectBoxActif, sync propriétaire payantSi vous avez besoin de sync multi-device natif
IsarAbandonné par l’auteurÀ migrer, pas de nouveau projet
HiveAbandonné par l’auteurIdem
RealmSync tuée par MongoDB en 2024À migrer, l’outil sans sync perd son intérêt principal

Pour la partie worker de synchronisation, on utilise deux mécanismes complémentaires :

Le WorkManager est optionnel : si votre app est ouverte plusieurs fois par jour (Next Sales, WhatsApp), le foreground worker suffit.

L’architecture en 3 couches

Le principe cardinal : l’écran ne sait rien du réseau, le worker ne sait rien de l’UI. Le Repository fait le pont entre les deux, mais uniquement via la base locale.

createOrder

transaction atomique

watch outbox
WHERE pending

POST avec retry

200 OK

UPDATE status=synced

stream réactif

UI · widgets Flutter
ref.watch orderSyncStatusProvider

Repository
createOrder / updateNote / ...

SQLite via Drift
orders + outbox

Sync Worker
Riverpod StreamProvider

API serveur
idempotent sur UUID client

Le découplage permet trois choses concrètes : (1) tester le Repository sans mock HTTP, (2) tester le Worker sans monter d’UI, (3) remplacer Riverpod par autre chose (Bloc, GetX) sans toucher au Repository ni au Worker.

Fichier 1 — Le schéma SQLite de l’outbox

Une table outbox = une action métier en attente d’envoi au serveur. Le pattern vient de l’architecture micro-services, formalisé par Chris Richardson (Chris Richardson — Transactional Outbox Pattern (microservices.io)), et s’applique intégralement au mobile. La clé de tout, c’est le id UUID généré côté client.

// 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()();             // id de l'entité métier
  TextColumn get operation => text()();               // 'CREATE', 'UPDATE', 'DELETE'
  TextColumn get payload => text()();                 // JSON du body HTTP
  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};
}

Fichier 2 — Le Repository (la règle d’or)

Une seule transaction atomique. Si insert(order) réussit et insert(outbox) échoue (ou l’inverse), on est dans un état incohérent — l’utilisateur voit sa donnée mais elle n’est jamais envoyée, ou l’inverse. SQLite via Drift garantit l’atomicité :

// 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();  // id STABLE, réutilisé au retry

    final order = Order(
      id: orderId,
      customerId: input.customerId,
      items: input.items,
      total: input.total,
      status: OrderStatus.pending,
      createdAt: DateTime.now(),
    );

    // ⚠️ Les deux inserts, ou aucun.
    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;
  }
}

Ce qu’on ne fait pas ici : aucun appel HTTP, aucune vérification de connectivité, aucune notification au worker. Le Repository ne connaît pas le réseau. Le worker s’en charge en lisant le stream Drift.

Fichier 3 — Le Sync Worker

La séquence complète, du tap à la confirmation serveur

APIWorkerSQLiteRepositoryUserAPIWorkerSQLiteRepositoryUserwatch() émet la nouvelle entréeUI passe de ⏱ à ✓ (stream)createOrder(input)1BEGIN TRANSACTION2INSERT order3INSERT outbox (status=pending)4COMMIT5Order local (instantané)6UPDATE outbox SET status=in_flight7POST /orders (idempotencyKey)8200 OK9UPDATE outbox SET status=synced10

La machine à états d’une entrée outbox

insert

worker prend

200 OK

erreur retriable

attempts >= 8

utilisateur retente

abandon

pending

in_flight

synced

failed

Le code du worker

// 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. Marquer in_flight (empêche un autre worker de la reprendre)
    await db.update(db.outboxEntries).replace(
      entry.copyWith(status: 'in_flight', lastAttemptAt: _now()),
    );

    try {
      // 2. Envoyer — l'API doit être idempotente sur entry.id
      await api.executeAction(
        idempotencyKey: entry.id,
        type: entry.aggregateType,
        operation: entry.operation,
        payload: jsonDecode(entry.payload),
      );

      // 3. Marquer synced
      await db.update(db.outboxEntries).replace(
        entry.copyWith(status: 'synced'),
      );
    } on RetriableException catch (_) {
      // 4a. 5xx, timeout, DNS → repasser pending pour 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 direct, pas de retry
      await db.update(db.outboxEntries).replace(
        entry.copyWith(status: 'failed'),
      );
    }
  }
}

Back-off exponentiel avec jitter (Marc Brooker, AWS 2015)

La référence canonique pour le retry mobile est Marc Brooker, « Exponential Backoff and Jitter », AWS Architecture Blog, 2015 (Marc Brooker, AWS Architecture Blog, 2015). Sans jitter, plusieurs clients qui retentent après le même événement forment un « thundering herd » qui synchronise leurs pics de trafic sur le backend.

Duration nextBackoff(int attempts) {
  const baseMs = 1000;         // 1 seconde
  const capMs = 5 * 60 * 1000; // cap à 5 minutes

  final exponential = baseMs * math.pow(2, attempts);
  final capped = math.min(exponential, capMs).toInt();

  // Full jitter : sleep aléatoire dans [0, capped]
  return Duration(milliseconds: math.Random().nextInt(capped));
}

Progression : ~2 s, 4 s, 8 s, 16 s, 32 s, 64 s, 128 s, 256 s (capé à 5 min). Après 8 tentatives (~40 min cumulées), l’entrée passe failed — au-delà, continuer draine la batterie sans résoudre le problème.

Fichier 4 — L’UI (les 4 états UX obligatoires)

Rappel du jour 2 : toute stratégie non-bloquante impose d’afficher 4 états UX — local, en attente, synchronisé, échec. Le widget lit le status via un 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),
        ),
      },
    );
  }
}

Cinq anti-patterns Flutter offline à surveiller

Anti-pattern
1. Vérifier Connectivity avant l'appel (signal peu fiable — hotspot captif) 2. Retry en boucle serrée sans back-off (drain batterie + DDoS backend) 3. Payload dans SharedPreferences (non-transactionnel, pas concurrent-safe) 4. Bloquer la UI pendant la sync (jank garanti dans le build()) 5. Utiliser Hive ou Isar en 2026 (bibliothèques abandonnées)
Correctif
1. Tenter l'appel, gérer l'erreur — le code réseau est source de vérité 2. Back-off exponentiel + jitter (Marc Brooker, AWS 2015), cap 5 min 3. SQLite via Drift dans une transaction atomique 4. Worker Riverpod indépendant du build, isolate si lourd 5. Drift comme default, sqflite pour SQL brut

Tests — pyramide typique

Type de testCibleVolume typique
Unit — Repositoryatomicité, écriture correcte de outbox~10 tests
Unit — Workermachine à états, gestion des erreurs~15 tests
Unit — nextBackoffprogression, cap, distribution du jitter~5 tests
Intégrationflow complet : createOrder → outbox → worker → synced~5 tests

Cas de test critiques à ne surtout pas oublier : atomic write (rollback si un des deux inserts échoue), idempotence (deux appels avec même id ne créent pas de doublon), concurrent workers (deux processes qui prennent la même entrée — un seul doit passer via le status in_flight), retour connectivité (le worker doit se déclencher dès connectivity_plus émet connected), app killed pendant in_flight (au reboot, remettre les in_flight en pending).

Ce qu’il reste à faire cette semaine

Le repo Flutter complet arrive en jour 3.bis (fin de semaine), avec :

01-offline-sync
Le code présenté ici, compilable, testé, avec démo GIF dans le README.
Test coverage 80%+
Tests unitaires Repository + Worker + nextBackoff, plus integration tests avec backend en mémoire.
Documentation
README avec architecture, décisions, roadmap. Blog post lié à cet article via cross-link.

Sources principales

Cet article a résonné ?

Delano Toungsi

Delano Toungsi

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

Me proposer une mission →

Un besoin mobile qui vous ressemble ?