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.
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èque | Statut 2026 | Recommandation |
|---|---|---|
| Drift (ex-Moor) | Actif, releases régulières | Default correct — type-safe, réactif, isolate threading |
| sqflite | Actif, wrapper SQLite bas-niveau | Si vous voulez du SQL brut sans ORM |
| ObjectBox | Actif, sync propriétaire payant | Si vous avez besoin de sync multi-device natif |
| Isar | Abandonné par l’auteur | À migrer, pas de nouveau projet |
| Hive | Abandonné par l’auteur | Idem |
| Realm | Sync 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 :
- App ouverte — un
StreamProviderRiverpod qui écoute simultanément le stream Drift de la table outbox et le stream de connectivité (Riverpod docs — StreamProvider) - App fermée —
flutter_workmanager, wrapper autour de Android WorkManager et iOSBGAppRefreshTask(flutter_workmanager sur pub.dev)
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.
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
La machine à états d’une entrée outbox
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
Tests — pyramide typique
| Type de test | Cible | Volume typique |
|---|---|---|
| Unit — Repository | atomicité, écriture correcte de outbox | ~10 tests |
| Unit — Worker | machine à états, gestion des erreurs | ~15 tests |
| Unit — nextBackoff | progression, cap, distribution du jitter | ~5 tests |
| Intégration | flow 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 :
Sources principales
- 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.
- Jour 2 — Perte de connexion mobile : les 3 stratégies possibles (contexte produit).
Cet article a résonné ?
Cet article vous a-t-il été utile ?
Cliquez une étoile pour laisser un mot
Delano Toungsi
Freelance CTO mobile · Missions, fractional & co-founder · Yaoundé full-remote