Perché offline-first non è solo "gestire l'errore di rete"
Molte app Flutter trattano l'assenza di connessione come un caso eccezionale: si mostra uno snackbar, si disabilita un pulsante e si spera che l'utente riprovi. Un'architettura offline-first ribalta la prospettiva: la rete è un dettaglio implementativo, mentre la sorgente di verità per la UI è il database locale. L'app legge e scrive sempre in locale, e un componente separato si occupa di riconciliare lo stato con il server quando è possibile.
I vantaggi sono concreti:
- Latenza percepita quasi nulla: la UI si aggiorna prima ancora che parta la richiesta HTTP.
- Resilienza: metropolitana, ascensori, connessioni instabili non bloccano l'utente.
- Meno stati di caricamento: spinner solo al primo avvio o quando i dati mancano davvero.
In cambio, bisogna affrontare tre problemi seri: come rappresentare le modifiche non ancora inviate, come sincronizzarle in modo affidabile, come gestire i conflitti. Vediamoli uno alla volta.
I tre livelli dell'architettura
UI (widget)
↕ stream reattivi
Repository ──► Local store (SQLite / Isar / Drift)
│ ▲
│ │
└──► Outbox ──► SyncEngine ──► API remota
- Local store: contiene le entità di dominio più alcuni metadati di sincronizzazione.
- Outbox: tabella di "intenzioni di scrittura" da inviare al server, in ordine.
- SyncEngine: processo in background che svuota l'outbox e scarica gli aggiornamenti remoti.
Il Repository espone alla UI solo stream sul database locale: non restituisce mai Future che dipendono dalla rete.
Modellare l'entità con i metadati di sync
Ogni record locale ha bisogno di informazioni aggiuntive rispetto al modello remoto:
enum SyncStatus { synced, pending, conflict }
class Note {
final String id; // UUID generato sul client
final String title;
final String body;
final DateTime updatedAt; // timestamp locale della modifica
final int remoteVersion; // versione conosciuta lato server
final SyncStatus status;
final bool deleted; // tombstone: cancellazione logica
const Note({
required this.id,
required this.title,
required this.body,
required this.updatedAt,
this.remoteVersion = 0,
this.status = SyncStatus.pending,
this.deleted = false,
});
}
Tre scelte da sottolineare:
- ID generati dal client (UUID v4 o v7): evitano il problema del "record senza id" finché il server non risponde, e rendono le operazioni idempotenti.
- Tombstone invece di
DELETEfisico: senza un record che testimonia la cancellazione, la sincronizzazione successiva riporterebbe in vita il dato. remoteVersion: serve per il controllo di concorrenza ottimistica (vedi più avanti).
Il pattern Outbox
L'errore più comune è tentare la chiamata HTTP dentro il repository e, in caso di fallimento, "ricordarsi" di riprovare. Il pattern Outbox rende invece la scrittura locale e la registrazione dell'intenzione di sincronizzare un'unica transazione atomica.
enum OpType { create, update, delete }
class OutboxEntry {
final int id; // autoincrement: definisce l'ordine
final String entityType; // 'note', 'task', ...
final String entityId;
final OpType op;
final String payload; // JSON dello stato da inviare
final int attempts;
final DateTime? nextAttemptAt;
const OutboxEntry({
required this.id,
required this.entityType,
required this.entityId,
required this.op,
required this.payload,
this.attempts = 0,
this.nextAttemptAt,
});
}
Il repository diventa quindi molto semplice e completamente sincrono rispetto alla rete:
class NoteRepository {
NoteRepository(this._db, this._sync);
final AppDatabase _db;
final SyncEngine _sync;
Stream<List<Note>> watchAll() => _db.watchNotes(includeDeleted: false);
Future<void> save(Note note) async {
final updated = note.copyWith(
updatedAt: DateTime.now().toUtc(),
status: SyncStatus.pending,
);
await _db.transaction(() async {
await _db.upsertNote(updated);
await _db.enqueueOutbox(
entityType: 'note',
entityId: updated.id,
op: updated.remoteVersion == 0 ? OpType.create : OpType.update,
payload: jsonEncode(updated.toJson()),
);
});
_sync.requestSync(); // best-effort, non attendiamo il risultato
}
Future<void> delete(String id) async {
await _db.transaction(() async {
await _db.markDeleted(id);
await _db.enqueueOutbox(
entityType: 'note',
entityId: id,
op: OpType.delete,
payload: '{}',
);
});
_sync.requestSync();
}
}
La UI riceve immediatamente il nuovo valore attraverso lo stream: è il cosiddetto optimistic update, ma senza l'acrobazia del rollback manuale, perché la modifica è già persistita.
Coalescing delle operazioni
Se l'utente modifica la stessa nota dieci volte offline, non ha senso inviare dieci PUT. Quando si accoda un update per un'entità che ha già un update pending non ancora in volo, conviene sostituire il payload della entry esistente. Analogamente, un delete su un'entità che ha ancora un create pending può cancellare entrambe le entry: il server non ha mai saputo dell'esistenza del record.
Il motore di sincronizzazione
Il SyncEngine ha un ciclo di vita chiaro: viene svegliato da un evento (avvio app, ritorno in foreground, ripristino della connettività, nuova scrittura) e processa la coda in modo seriale.
class SyncEngine {
SyncEngine(this._db, this._api, this._connectivity);
final AppDatabase _db;
final ApiClient _api;
final Connectivity _connectivity;
bool _running = false;
bool _pendingRequest = false;
void start() {
_connectivity.onConnectivityChanged.listen((results) {
if (!results.contains(ConnectivityResult.none)) requestSync();
});
requestSync();
}
Future<void> requestSync() async {
if (_running) {
_pendingRequest = true; // non accodiamo run paralleli
return;
}
_running = true;
try {
await _pushOutbox();
await _pullChanges();
} catch (e, s) {
// logging / crash reporting: non rilanciare, il retry è programmato
} finally {
_running = false;
if (_pendingRequest) {
_pendingRequest = false;
unawaited(requestSync());
}
}
}
}
Push: svuotare l'outbox
Future<void> _pushOutbox() async {
final entries = await _db.pendingOutbox(limit: 50);
for (final entry in entries) {
try {
final result = await _api.apply(entry); // POST/PUT/DELETE
await _db.transaction(() async {
await _db.applyServerVersion(entry.entityId, result.version);
await _db.markSynced(entry.entityId);
await _db.removeOutbox(entry.id);
});
} on ConflictException catch (e) {
await _resolveConflict(entry, e.serverState);
await _db.removeOutbox(entry.id);
} on NetworkException {
await _db.scheduleRetry(entry, _backoff(entry.attempts));
break; // ordine garantito: fermiamoci alla prima entry non inviabile
} on ApiException catch (e) {
if (e.statusCode >= 400 && e.statusCode < 500) {
// errore permanente: non ha senso riprovare all'infinito
await _db.moveToDeadLetter(entry);
} else {
await _db.scheduleRetry(entry, _backoff(entry.attempts));
break;
}
}
}
}
Duration _backoff(int attempts) {
final seconds = math.min(300, math.pow(2, attempts).toInt());
final jitter = math.Random().nextInt(1000);
return Duration(seconds: seconds, milliseconds: jitter);
}
Due dettagli spesso trascurati:
- Backoff esponenziale con jitter: senza jitter, migliaia di client che tornano online dopo un disservizio colpiscono il backend nello stesso istante.
- Dead letter: un
422causato da un payload non valido non si risolve riprovando. Meglio spostare l'operazione in una tabella separata, notificare l'utente e permettere di scartare o correggere.
Pull: scaricare gli aggiornamenti remoti
La sincronizzazione discendente dovrebbe essere incrementale, non un download completo. Il pattern classico è il delta sync basato su cursore:
Future<void> _pullChanges() async {
var cursor = await _db.readSyncCursor('note');
while (true) {
final page = await _api.changesSince(cursor, limit: 200);
await _db.transaction(() async {
for (final remote in page.items) {
final local = await _db.findNote(remote.id);
if (local == null || local.status == SyncStatus.synced) {
await _db.upsertFromRemote(remote);
} else {
// esiste una modifica locale non ancora inviata
await _resolveConflict(local, remote);
}
}
await _db.writeSyncCursor('note', page.nextCursor);
});
if (!page.hasMore) break;
cursor = page.nextCursor;
}
}
Il cursore può essere un timestamp server oppure, meglio, un numero di sequenza monotono: i timestamp soffrono di clock skew e di record modificati nello stesso millisecondo.
Gestire i conflitti
Un conflitto si verifica quando lo stesso record è stato modificato sia in locale sia sul server dall'ultima sincronizzazione. Le strategie principali:
| Strategia | Quando usarla | Rischio |
|---|---|---|
| Last Write Wins | Dati poco critici, singolo utente su più device | Perdita silenziosa di modifiche |
| Server wins | Dati autoritativi (prezzi, permessi) | Frustrazione dell'utente |
| Merge per campo | Entità con campi indipendenti | Complessità, stati incoerenti |
| Prompt all'utente | Contenuti editoriali importanti | Attrito nella UX |
| CRDT | Editing collaborativo realtime | Molto complesso, payload pesanti |
Il controllo di concorrenza ottimistica è il prerequisito per rilevarli: il client invia la remoteVersion che conosce (tipicamente in un header If-Match con un ETag), e il server risponde 409 Conflict se nel frattempo la versione è cambiata.
Future<void> _resolveConflict(Note local, Note server) async {
switch (conflictPolicy) {
case ConflictPolicy.serverWins:
await _db.upsertFromRemote(server);
case ConflictPolicy.lastWriteWins:
final winner = local.updatedAt.isAfter(server.updatedAt) ? local : server;
await _db.upsertNote(winner.copyWith(remoteVersion: server.remoteVersion));
case ConflictPolicy.manual:
await _db.upsertNote(local.copyWith(status: SyncStatus.conflict));
await _db.saveConflictSnapshot(local.id, server); // per la UI di merge
}
}
Suggerimento pratico: anche se si adotta Last Write Wins, conviene archiviare la versione perdente in una tabella di storico. Costa poco e salva da segnalazioni di "dati spariti" impossibili da diagnosticare.
Comunicare lo stato all'utente
Un'app offline-first deve essere onesta. Tre elementi di UI fanno la differenza:
- Badge per elemento: una piccola icona di "in attesa di sincronizzazione" sui record con
status != synced. - Indicatore globale: numero di operazioni pending e timestamp dell'ultima sincronizzazione riuscita.
- Azione manuale: pull-to-refresh che chiama
requestSync(), perché gli utenti si aspettano di poter forzare.
class SyncBanner extends StatelessWidget {
const SyncBanner({super.key, required this.pendingCount});
final int pendingCount;
@override
Widget build(BuildContext context) {
if (pendingCount == 0) return const SizedBox.shrink();
return MaterialBanner(
leading: const Icon(Icons.cloud_upload_outlined),
content: Text('$pendingCount modifiche in attesa di sincronizzazione'),
actions: [
TextButton(
onPressed: () => context.read<SyncEngine>().requestSync(),
child: const Text('Sincronizza ora'),
),
],
);
}
}
Testare la sincronizzazione
La logica di sync è il punto in cui si annidano i bug più costosi, ed è per fortuna facilmente testabile perché non dipende dai widget. Casi da coprire con unit test:
- Scrittura offline → outbox contiene esattamente una entry.
- Dieci update consecutivi → una sola entry dopo il coalescing.
- Errore di rete a metà coda → le entry successive non vengono saltate.
409dal server → il conflitto viene applicato secondo la policy.- Doppio invio della stessa operazione → il server, grazie all'ID client, non crea duplicati.
- Riavvio dell'app con outbox non vuoto → la coda riparte.
Un FakeApiClient con un flag isOnline e una lista di risposte programmate permette di simulare l'intero ciclo in millisecondi.
Errori da evitare
- Usare solo
connectivity_plusper decidere se si è online: il pacchetto dice che esiste un'interfaccia attiva, non che il server è raggiungibile (captive portal, DNS bloccato). Trattate sempre il fallimento della richiesta come fonte di verità. - Eseguire il sync in parallelo su più isolate senza lock: si ottengono scritture duplicate. Serializzate la coda.
- Ignorare l'ordine delle operazioni: creare un figlio prima del padre genera errori di integrità referenziale lato server.
- Sincronizzare tutto a ogni avvio: con dataset grandi diventa insostenibile. Investite subito in un endpoint di delta sync.
- Non versionare lo schema locale: le migrazioni del database offline sono inevitabili; pianificatele dall'inizio.
Conclusioni
L'offline-first non è una funzionalità da aggiungere alla fine, ma una decisione architetturale che condiziona modelli, repository e API. Il costo iniziale è reale — un database locale, una coda persistente, una policy di conflitti — ma il risultato è un'app che risponde istantaneamente, sopravvive alle connessioni scadenti e riduce drasticamente il carico sul backend.
Se state partendo da zero, implementate nell'ordine: database locale come sorgente di verità, poi l'outbox, poi il delta sync, e solo alla fine la gestione fine dei conflitti. Ogni passo porta valore anche da solo.