Optimistic UI & Sync Status
This is Part 9 of the Offline-First Flutter series. The engine works: local-first writes (Part 4), push, pull, conflict resolution. But to the user, sync is invisible — which is mostly good, and occasionally confusing ("did my note save? is it on my other phone yet?"). This part is about communicating sync state without sacrificing the instant feel.
You already have optimistic UI — you just may not have named it. Because writes go to the local DB and the UI reacts to the local DB (Part 4), the screen updates before (and independently of) the server confirming anything. That is optimistic UI. The work now is to layer sync-status feedback on top: per-note "synced / pending / failed" and a global indicator — so the user trusts the system without ever waiting on it.
Stack: Drift + Riverpod, Flutter 3.38 / Dart 3.12. Builds on all prior parts.
Optimistic UI, made explicit
Optimistic UI means: assume the operation will succeed, update the screen immediately, and reconcile later if reality disagrees. The opposite — pessimistic UI — shows a spinner and waits for the server before updating.
// Pessimistic (what we're NOT doing): user waits, UI blocks.
setState(() => _loading = true);
await api.createNote(...); // spinner spins...
setState(() { _notes.add(note); _loading = false; });
// Optimistic (offline-first): instant, no spinner, sync happens later.
await repo.createNote(...); // local write → reactive stream → UI updates NOW
// the sync engine pushes in the background ([Part 6])
In offline-first, optimistic UI isn't a technique you add — it's a consequence of the architecture. The local DB is the source of truth, so showing local state immediately is simply showing the truth. The only thing left is to indicate how far that truth has propagated to the server.
The analogy: messaging ticks
Analogy — WhatsApp checkmarks. When you send a message, it appears instantly in your chat (optimistic). Then a tiny indicator tells the propagation story: a clock (sending), one check (reached the server), two checks (delivered everywhere), a red "!" (failed). You never wait to keep typing — the ticks just narrate delivery in the corner.
That's exactly the UX we want for notes:
| State | Meaning | Icon idea | | --- | --- | --- | | Pending | Saved locally, not yet pushed | ☁️↑ / clock | | Synced | Confirmed on the server | ✓ | | Failed | Push failed (will retry / needs attention) | ⚠️ red |
Deriving per-note sync status
We already have most of the signal in our metadata. Add one column for error state, then derive a status:
// Migration ([Part 3]): add a nullable error column.
class Notes extends Table {
// ... existing columns ...
TextColumn get syncError => text().nullable()(); // set on permanent push failure
}
enum SyncStatus { synced, pending, failed }
SyncStatus statusOf(Note n) {
if (n.syncError != null) return SyncStatus.failed;
if (n.isDirty) return SyncStatus.pending; // unsynced local changes
return SyncStatus.synced; // clean = matches server
}
Status is derived, not stored separately.
isDirtyalready means "pending"; a non-nullsyncErrormeans "failed"; clean means "synced." Deriving keeps the status always consistent with the actual sync metadata — there's no second source of truth to drift out of sync. (The sync engine setssyncErroron a permanent failure and clears it on success/retry; transient failures just staypending.)
And the per-note indicator widget — reactive, of course, because it reads the same local row:
class SyncBadge extends StatelessWidget {
const SyncBadge(this.note, {super.key});
final Note note;
@override
Widget build(BuildContext context) => switch (statusOf(note)) {
SyncStatus.synced => const Icon(Icons.cloud_done, size: 16, color: Colors.green),
SyncStatus.pending => const Icon(Icons.cloud_upload, size: 16, color: Colors.grey),
SyncStatus.failed => const Icon(Icons.error_outline, size: 16, color: Colors.red),
};
}
Since the note list comes from a reactive stream, the badge updates automatically as the sync engine flips
isDirty/syncError. Create a note → badge shows pending → sync runs → badge flips to synced. No manual wiring, just derived state over the SSoT.
The global sync indicator
Beyond per-note state, users like a single "everything's up to date" signal. Two useful global providers:
// How many changes are still pending push?
final pendingCountProvider = StreamProvider<int>((ref) {
final db = ref.watch(databaseProvider);
final q = db.selectOnly(db.notes)
..addColumns([db.notes.id.count()])
..where(db.notes.isDirty.equals(true));
return q.map((row) => row.read(db.notes.id.count()) ?? 0).watchSingle();
});
// Is a sync actively running right now? (set by the sync engine)
final syncRunningProvider = StateProvider<bool>((ref) => false);
A small status line in the app bar ties it together:
class SyncStatusLine extends ConsumerWidget {
const SyncStatusLine({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final running = ref.watch(syncRunningProvider);
final pending = ref.watch(pendingCountProvider).value ?? 0;
final online = ref.watch(onlineProvider).value ?? true; // Part 5
final (text, icon) = switch ((running, pending, online)) {
(true, _, _) => ('Syncing…', Icons.sync),
(_, 0, _) => ('All changes saved', Icons.cloud_done),
(_, _, false) => ('Offline — $pending pending', Icons.cloud_off),
(_, _, true) => ('$pending change(s) to sync', Icons.cloud_upload),
};
return Row(mainAxisSize: MainAxisSize.min,
children: [Icon(icon, size: 16), const SizedBox(width: 4), Text(text)]);
}
}
Notice the message for "offline with pending changes" is calm and informative, not alarming. Offline-first means pending changes are normal and safe, not errors — the UI should reflect that confidence.
Error & retry UX
Most failures are transient and self-heal (the sync engine retries — Part 6), so they should be quiet (stay pending, no scary dialogs). Only permanent failures (validation rejected, auth expired) deserve user attention:
// In the sync engine, on a PERMANENT failure (e.g. 400/401):
await (db.update(db.notes)..where((n) => n.id.equals(note.id)))
.write(NotesCompanion(syncError: Value('Rejected: ${e.message}')));
// → statusOf() now returns failed → red badge → user can act.
A failed note offers a manual retry (and/or "edit to fix"):
if (statusOf(note) == SyncStatus.failed)
TextButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
onPressed: () async {
// clear the error, re-mark dirty, kick a sync
await ref.read(noteRepositoryProvider).clearError(note.id);
ref.read(syncEngineProvider).syncNow();
},
),
Triage failures into transient vs permanent (Part 6). Transient → retry silently, keep the calm "pending" badge. Permanent → surface a clear, actionable "failed, tap to retry/fix" affordance. Never block the whole UI for a single note's sync problem — isolate it to that note's badge.
What about rollback?
In classic optimistic UI you sometimes roll back when the server rejects an action (e.g. a "like" that the server refuses). In offline-first this is rarer and gentler:
- The local DB is the source of truth, so a rejected push doesn't "undo" the local note — the note still exists locally; it's just marked failed until resolved.
- True rollback applies only when the server is authoritative over whether the data may exist at all (e.g. a server-side validation rule). Then you'd surface the error and let the user edit or discard — you generally don't silently delete their content.
Prefer "mark failed + let the user fix" over silent rollback for user-generated content. Yanking a note off the screen because the server didn't like it is a worse experience than flagging it and offering a fix. Rollback is for ephemeral actions, not for the user's data.
Practice Challenges
Challenge 1 — Name the pattern. A note appears on screen the instant you tap save, before any server response. What is this called and why does the architecture give it for free?
Show solution
Optimistic UI. Because writes go to the local DB (the SSoT) and the UI reacts to the local DB (Part 4), the screen reflects the change immediately, independent of the server — optimism is a consequence of local-first, not an added trick.
Challenge 2 — Derive, don't store. Why compute SyncStatus from isDirty/syncError instead of storing a separate status column?
Show solution
Deriving keeps status always consistent with the real sync metadata — there's no second field to fall out of sync. A stored status would need updating in lockstep with isDirty/syncError and could drift; derivation can't.
Challenge 3 — Auto-updating badge. Why does the per-note sync badge update without any explicit refresh code?
Show solution
The note comes from a reactive stream over the local DB. When the sync engine flips isDirty/syncError, the row changes, the stream re-emits, and the badge rebuilds with the new derived status — the same SSoT + reactive mechanism that powers the whole UI.
Challenge 4 — Quiet vs loud. A push fails with a timeout (503). Should the UI show a red error? What about a 400 validation error?
Show solution
The 503/timeout is transient — keep it quiet (stay pending, auto-retry); no scary error. The 400 is permanent — set syncError, show the failed badge with an actionable retry/fix, since silent retries would loop forever and the user must intervene.
Challenge 5 — Rollback vs flag. A note is rejected by a server validation rule. Should you delete it from the screen? Why or why not?
Show solution
No — don't silently delete the user's content. Mark it failed (syncError) and offer an actionable fix/retry. The local DB is the source of truth; yanking the note away is a worse experience than flagging it and letting the user correct or discard it.
Questions to test yourself
Q1 (basic). What is optimistic UI, and how does offline-first provide it automatically?
Show answer
Updating the UI immediately, assuming success, and reconciling later. Offline-first provides it automatically because the UI reflects the local DB (SSoT), which is written instantly — so the screen updates before/independent of the server.
Q2 (basic). What are the three per-note sync states and what signal drives each?
Show answer
Synced (clean — isDirty == false), pending (isDirty == true, no error), failed (syncError != null). Status is derived from the existing sync metadata.
Q3 (intermediate). Why derive sync status instead of storing it?
Show answer
So it stays consistent with the real metadata (isDirty/syncError) — a single source of truth. A stored status field would need manual lockstep updates and could drift out of sync.
Q4 (intermediate). How should transient vs permanent push failures differ in the UI?
Show answer
Transient (timeout/503): stay pending, retry silently, no alarming UI. Permanent (400/401): set syncError, show a failed badge with an actionable retry/fix. Don't loop on permanent errors or alarm users about self-healing transient ones.
Q5 (intermediate). Why should the "offline with pending changes" message be calm rather than an error?
Show answer
Because in offline-first, pending changes are normal and safe — they're saved locally and will sync later. Presenting it as an error would undermine the user's trust in a system that's working exactly as designed.
Q6 (advanced). How does offline-first change the role of rollback compared to classic optimistic UI?
Show answer
Classic optimistic UI rolls back when the server rejects an action. In offline-first the local DB is authoritative for existence, so a rejected push doesn't undo the local data — the item is marked failed until resolved. True rollback applies only to ephemeral, server-authoritative actions; for user content you flag + let the user fix, never silently delete.
Wrapping up
- Optimistic UI is free in offline-first: the UI reflects the instantly-written local DB, so updates appear before the server responds.
- Layer on sync feedback like messaging ticks: per-note synced / pending / failed, derived from
isDirty/syncError(never a separate stored status), and auto-updating via reactive streams. - Add a global indicator (pending count, "syncing…", "all changes saved", "offline — N pending") with calm offline messaging.
- Triage failures: transient → quiet auto-retry (stay pending); permanent → actionable failed badge with retry/fix.
- Prefer "mark failed + let the user fix" over silent rollback for user content.
In Part 10 we make sync happen even when the app isn't open: background sync — workmanager periodic tasks, sync-on-reconnect, and retry/backoff that respects the platform's rules.