100 Questions to Master Offline-First Flutter
This is Part 12 — the finale of the Offline-First Flutter series. The previous eleven parts built FieldNotes layer by layer; this is where you prove you can architect and reason about offline-first sync.
How to use this bank:
- 100 questions, grouped by topic, tagged [Basic], [Medium], [Advanced] and marked (Theory) or (Coding).
- Each has a Hint and a separate Solution. Try cold first, peek at the hint if stuck, only then check the solution.
- For (Coding) questions, sketch the Drift/Riverpod/sync code yourself.
- After the 100, there are 10 coding mini-exercises with full solutions, ending in a capstone that assembles the whole sync engine.
If you can explain the why on all 100 without hints, you can design offline-first sync at a senior level. Let's go.
Section A — Foundations (Q1–9)
Q1. [Basic] (Theory) Define offline-first in one sentence.
Hint
Local first, sync separately. See Part 1.
Solution
Every read and write hits local storage first (instant, network-independent), and syncing with the server happens separately in the background — the app never waits on or blocks for the network.
Q2. [Basic] (Theory) What is the single source of truth in this architecture?
Hint
Not the server.
Solution
The local database. The UI reads only from it; the sync engine keeps it eventually consistent with the server.
Q3. [Basic] (Theory) How does offline-first differ from "online-first with a cache"?
Hint
Writes.
Solution
A cache is a read fallback on a network-primary design, so writes still fail offline. Offline-first makes local storage primary for reads and writes; the network is never on the critical path and sync is first-class.
Q4. [Medium] (Theory) Name the two independent data flows in the architecture.
Hint
Fast vs slow.
Solution
(1) User flow: UI → Repository → Local DB (synchronous, instant, local). (2) Sync flow: Sync Engine ↔ Server (asynchronous, background). The user waits only on the first.
Q5. [Medium] (Theory) Why must records use client-generated UUIDs?
Hint
Created before the server.
Solution
Records are created offline, before the server sees them, so there's no round-trip to allocate a server id. The client mints a UUID for a stable, collision-free identity from creation.
Q6. [Medium] (Theory) Why is delete modeled as a soft delete?
Hint
Deletion must sync.
Solution
A deletion is information that must reach the server/other devices. Physically removing the row loses it, so the note resurrects on next pull. A tombstone keeps the deletion syncable.
Q7. [Advanced] (Theory) Why does offline-first feel faster even on a good network?
Hint
Critical path.
Solution
Reads/writes hit local storage (sub-millisecond) instead of network round-trips (100–2000ms) on the critical path. The UI updates instantly from local data; sync happens invisibly, so there are no per-action spinners.
Q8. [Medium] (Theory) When might you choose a managed framework (PowerSync/Brick) over hand-building?
Hint
Ship vs learn.
Solution
When you want to ship standard CRUD-over-Postgres fast with battle-tested sync/conflict handling and less code, and don't need full custom control. Hand-building is for learning, custom needs, or avoiding lock-in.
Q9. [Advanced] (Theory) Why are UUIDs and soft deletes both consequences of the offline-first principle?
Hint
Before the server.
Solution
Both creation and deletion happen offline, before the server is involved. A record needs identity at creation (→ client UUID), and a deletion is data that must sync (→ tombstone). Both fall directly out of "local first, sync later."
Section B — Data modeling for sync (Q10–18)
Q10. [Basic] (Theory) What two categories of columns does an offline-first table have?
Hint
User vs engine.
Solution
Domain data (title, body) and sync metadata (id/UUID, updatedAt, version, isDirty, isDeleted).
Q11. [Basic] (Theory) What does isDirty mean and what does it drive?
Hint
Unsynced.
Solution
It marks rows with unsynced local changes and drives the push: the engine pushes dirty rows and clears the flag on success.
Q12. [Medium] (Coding) Write the Drift "delete" statement the offline-first way for note x.
Hint
Update, not delete.
Solution
(update(notes)..where((n) => n.id.equals('x'))).write(
const NotesCompanion(isDeleted: Value(true), isDirty: Value(true)));
Q13. [Medium] (Theory) Why store updatedAt in UTC?
Hint
Common reference.
Solution
Devices/servers span time zones; UTC is a single common reference so timestamp comparisons in conflict resolution are meaningful.
Q14. [Medium] (Theory) What does the version column add over updatedAt?
Hint
Clock-free.
Solution
A monotonically incrementing counter gives clock-independent conflict detection ("you edited v3 but server has v5"), unlike timestamps which suffer clock drift.
Q15. [Medium] (Coding) Why filter isDeleted.equals(false) in the UI query?
Hint
Tombstones still exist.
Solution
Tombstoned rows remain in the table so the deletion can sync, but the user shouldn't see them — so the UI query excludes them.
Q16. [Advanced] (Theory) Why does Drift's reactive .watch() matter for offline-first specifically?
Hint
Background writes.
Solution
Background sync writes into the local DB, and reactive streams auto re-emit on any table change — so the UI updates when sync brings data without UI code. SSoT + reactive queries = free background UI updates.
Q17. [Medium] (Theory) What's the role of the NotesCompanion in updates?
Hint
Partial.
Solution
It lets you set only the columns you mean to change (others stay Value.absent()), so partial updates don't clobber other fields/metadata.
Q18. [Advanced] (Theory) Why include both updatedAt and version rather than one?
Hint
Different roles.
Solution
updatedAt is convenient for LWW ordering but clock-sensitive; version gives robust, clock-free conflict detection. Keeping both lets you start with timestamps and harden with versions.
Section C — Migrations & versioning (Q19–27)
Q19. [Basic] (Theory) What two things change for a Drift migration?
Hint
Version + strategy.
Solution
Increment schemaVersion and add the step(s) in onUpgrade (plus update the table). onCreate handles fresh installs.
Q20. [Basic] (Theory) Why are migrations higher-stakes in offline-first?
Hint
Only copy.
Solution
The local DB is the SSoT and may hold un-synced data that exists nowhere else, so a destructive migration causes permanent data loss — unlike a re-fetchable online cache.
Q21. [Medium] (Coding) Add a non-null color column (default '#FFF') in v3. Give the onUpgrade branch.
Hint
addColumn.
Solution
if (from < 3) { await m.addColumn(notes, notes.color); }
(with color declared .withDefault(const Constant('#FFF'))).
Q22. [Medium] (Theory) Why if (from < N) not if (from == N-1) in onUpgrade?
Hint
Long jump.
Solution
A user may upgrade across multiple versions at once; < N runs each step for everyone below that version, so all needed steps execute in order on a long jump.
Q23. [Medium] (Theory) Why must a new column have a default or be nullable?
Hint
Existing rows.
Solution
Existing rows have no value for it; SQLite can't add a non-null, no-default column to a populated table. A default/nullable gives old rows a valid value.
Q24. [Advanced] (Theory) How can a migration trigger an accidental full re-sync?
Hint
isDirty.
Solution
By setting isDirty = true (or bumping version) on rows, making the engine push the whole database next launch. Don't mark dirty for representation-only migrations.
Q25. [Medium] (Coding) A dev migrates via m.drop(notes); m.createTable(notes);. Why is it catastrophic?
Hint
Data loss.
Solution
It deletes all notes, including un-synced ones that exist only locally — irreversible loss. Migrate in place (addColumn/backfill), never drop user data.
Q26. [Advanced] (Theory) Why prefer "add + backfill + keep old" over dropping a column, and when can you drop?
Hint
Transition.
Solution
Dropping is destructive and might erase the only copy of un-synced data. Add/backfill preserves data during transition; drop only in a later release once all clients migrated and nothing un-synced depends on it.
Q27. [Advanced] (Theory) Which migration must you always test, and with what tool?
Hint
Long jump + verifier.
Solution
The oldest supported → latest upgrade, using Drift's schema verifier (schema dump/generate), asserting existing rows survive with sensible defaults.
Section D — Repository & reactive UI (Q28–36)
Q28. [Basic] (Theory) What is the repository's role?
Hint
Doorway.
Solution
The single doorway between UI and data — the only thing the UI talks to; it exposes domain ops and hides Drift/HTTP/sync.
Q29. [Basic] (Coding) What Riverpod provider type exposes the reactive notes list?
Hint
Stream.
Solution
A StreamProvider<List<Note>> wrapping the repository's watchNotes() stream (yielding AsyncValue).
Q30. [Medium] (Theory) Why does createNote return instantly even offline?
Hint
Local write only.
Solution
It only writes a local row and sets isDirty — no network call. It's durably saved on local commit; pushing happens later in the sync engine.
Q31. [Medium] (Coding) Trace what puts a newly created note on screen.
Hint
Reactive chain.
Solution
Repo write → local INSERT → Drift .watch() re-emits → notesProvider emits AsyncData → ConsumerWidget rebuilds with the note.
Q32. [Medium] (Theory) Why can the sync engine be added later without changing widgets?
Hint
Behind the boundary.
Solution
The UI depends only on the repository interface and watches the local DB, never the network. Sync sits behind that boundary, writing to the same table whose reactive streams already update the UI.
Q33. [Medium] (Theory) Why should the repository return domain types, not HTTP DTOs?
Hint
Decouple.
Solution
So the UI is independent of storage/transport shape; schema/API/sync changes don't ripple into widgets, and the repository can reconcile local+remote behind a stable contract.
Q34. [Basic] (Coding) A widget does ref.watch(databaseProvider).select(...). What's wrong?
Hint
Leak.
Solution
It bypasses the repository, coupling UI to Drift. Move the query into the repository and watch a provider exposing it.
Q35. [Advanced] (Theory) Why does writing server-pulled notes into the local table update the UI for free?
Hint
Same table.
Solution
The UI watches the local table; any change (including sync writes) makes watchNotes() re-emit through notesProvider to the widget. The reactive read path is reused by sync automatically.
Q36. [Advanced] (Theory) Where does the sync engine attach, and why is that good design?
Hint
The seam.
Solution
Behind the repository (triggered by connectivity). Because the UI only knows the repository + reactive providers, sync is purely additive — no widget changes. Clean boundaries make big features addable without rewrites.
Section E — Connectivity (Q37–45)
Q37. [Basic] (Theory) What does connectivity_plus actually tell you?
Hint
Interface.
Solution
That a network interface exists (wifi/cellular/none) and when it changes — not whether the internet/your server is reachable.
Q38. [Basic] (Theory) What is a reachability check?
Hint
Place the call.
Solution
A tiny request (e.g. HTTP HEAD /health) with a short timeout that confirms your server actually answered. Failure/timeout → treat as offline.
Q39. [Medium] (Theory) How do you combine the two layers into one signal?
Hint
Gate then confirm.
Solution
Use connectivity_plus as a cheap gate (no interface → offline), then confirm with a reachability probe. Online = interface present and server reachable.
Q40. [Medium] (Theory) Why probe your own backend, not a generic site?
Hint
What sync talks to.
Solution
The sync engine talks to your backend, so that's what must be reachable. A generic site being up doesn't prove your API is reachable/unblocked.
Q41. [Medium] (Coding) Why trigger sync on wasOffline && isOnline rather than every "online" emission?
Hint
Edge.
Solution
To sync on the reconnect edge, not repeatedly while online — avoiding sync storms (especially with flapping connections).
Q42. [Medium] (Theory) Two defenses against flapping connections?
Hint
Debounce + resilient.
Solution
Debounce the online signal so brief drops/returns don't propagate, and make the sync engine resilient (idempotent, retry-capable) so a sync during a blip fails gracefully.
Q43. [Advanced] (Theory) Why must sync correctness not depend on the connectivity signal?
Hint
Best-effort hint.
Solution
The signal is best-effort and can be wrong (says online a moment before a request fails). Correctness must live in an idempotent, failure-handling sync engine; the signal is just "probably worth trying now."
Q44. [Basic] (Theory) Why is an offline banner reassurance, not an error?
Hint
Writes still work.
Solution
Writes already succeed locally; being offline doesn't block the user or lose data. The banner just notes that syncing is paused.
Q45. [Advanced] (Coding) A user on captive-portal wifi sees "online" and syncs time out. Diagnose + fix.
Hint
Add reachability.
Solution
connectivity_plus confirms only an interface; the portal blocks your API. Add a reachability probe to /health and define online as interface AND server reachable, so the portal reads as offline-for-sync.
Section F — The outbox / push (Q46–55)
Q46. [Basic] (Theory) What is an outbox?
Hint
Durable pending.
Solution
A durable record of pending changes needing to reach the server — written instantly on edit, drained when online, items removed only on confirmed success.
Q47. [Basic] (Theory) When may a dirty row be marked clean?
Hint
After ack.
Solution
Only after the server acknowledges the push (and only if still at the pushed version). Sooner risks marking unsent changes clean.
Q48. [Medium] (Theory) How do client UUIDs give exactly-once semantics?
Hint
Idempotency key.
Solution
They're idempotency keys: with an upsert endpoint keyed by the UUID, a retried push updates the same record instead of duplicating. At-least-once + idempotent = effectively exactly-once.
Q49. [Medium] (Coding) Describe the dirty-during-push race and the fix.
Hint
Compare-and-clear.
Solution
If the user edits a row (v5→v6) during the push of v5, clearing isDirty after the v5 ack loses v6. Compare-and-clear: only clear if the row is still at the pushed version; a mid-push edit changes the version so the conditional update matches nothing and it stays dirty.
Q50. [Medium] (Theory) When is it safe to purge a tombstone?
Hint
After ack.
Solution
Only after the server acknowledges the delete. Purging earlier loses the deletion locally and the note resurrects on next pull.
Q51. [Medium] (Theory) Transient vs permanent failure — handle each how?
Hint
Retry vs surface.
Solution
Transient (timeout/503): leave dirty, retry with backoff. Permanent (400/401): don't loop — surface it (fix data/re-auth). Treating all the same loses data or spins forever.
Q52. [Basic] (Theory) In the dirty-flag design, what selects rows to push?
Hint
The flag.
Solution
All rows where isDirty = true; push each, clear on success, leave dirty on failure.
Q53. [Medium] (Coding) Write a backoff duration for attempt n (cap 5 min).
Hint
Exponential.
Solution
Duration backoff(int n) => Duration(seconds: (1 << n).clamp(1, 300));
Q54. [Advanced] (Theory) Dirty-flag vs operation-log outbox — when each?
Hint
State vs sequence.
Solution
Dirty-flag stores current state of changed rows — simple, for independent records (FieldNotes). Operation-log stores ordered ops (create/update/delete + payload) — preserves intent/order for collaborative/event-sourced data, at more complexity.
Q55. [Advanced] (Theory) Why design the endpoint as PUT /notes/{id} rather than POST /notes?
Hint
Idempotent.
Solution
PUT keyed by the client UUID is an idempotent upsert — safe to retry (no duplicates). A blind POST creates a new row each call, so a retried-after-timeout push duplicates the record.
Section G — Delta pull & merge (Q56–65)
Q56. [Basic] (Theory) What is delta pull?
Hint
Since cursor.
Solution
Fetching only records changed since the last sync (via a cursor), not the whole dataset each time.
Q57. [Basic] (Theory) What is the sync cursor?
Hint
Bookmark.
Solution
A value marking "how far I'm caught up" (a server change-sequence or serverUpdatedAt), stored locally and advanced after applying a delta.
Q58. [Medium] (Theory) Why must the pull response include deletions?
Hint
Otherwise lingers.
Solution
Otherwise the client never learns a record was deleted elsewhere, so it lingers forever. The server returns deletedIds (its tombstones) for the client to remove.
Q59. [Medium] (Coding) During merge, the local row is dirty and a remote change arrives. What do you do?
Hint
Don't clobber.
Solution
Treat it as a conflict (Part 8) — never overwrite. Overwriting a dirty row destroys un-synced local edits. Overwrite only when the local row is clean.
Q60. [Medium] (Theory) Why advance the cursor in the same transaction that applies a page?
Hint
Crash-safe.
Solution
So apply-and-bookmark are atomic and resumable: a crash mid-pull re-fetches and re-applies that page harmlessly rather than skipping or partially applying it.
Q61. [Medium] (Theory) Why "push then pull"?
Hint
Up-to-date world.
Solution
Pushing first puts local changes on the server before pulling, so the delta reflects an up-to-date world and conflicts are more likely resolved at push than discovered on pull — faster convergence.
Q62. [Medium] (Theory) How is pull made idempotent?
Hint
Upsert/delete by id.
Solution
Changes apply via upsert-by-id (re-applying yields the same row); deletions via delete-by-id (a no-op if absent); the cursor advances transactionally so re-runs are harmless.
Q63. [Basic] (Coding) What does a null cursor on first run cause?
Hint
Everything.
Solution
The server returns everything (paginated) — the initial full sync — and subsequent pulls become deltas.
Q64. [Advanced] (Theory) Why prefer a server change-sequence over device timestamps for the cursor?
Hint
One clock.
Solution
A server sequence comes from a single authoritative counter, avoiding cross-device clock drift, timezone issues, and missed/duplicated items at timestamp boundaries — giving exact "everything after here" semantics.
Q65. [Advanced] (Theory) How do remote changes reach the screen with no UI code?
Hint
SSoT + reactive.
Solution
Pull upserts into the local table (the SSoT); Drift's reactive .watch() re-emits and notesProvider rebuilds the UI. The reactive read path from Part 4 carries sync writes to the screen automatically.
Section H — Conflict resolution (Q66–76)
Q66. [Basic] (Theory) When does a conflict occur?
Hint
Two places.
Solution
When the same record is changed in two places since their last common sync, producing two divergent versions to reconcile.
Q67. [Basic] (Theory) What's the trade-off of last-write-wins?
Hint
Simple but lossy.
Solution
Trivial and deterministic, but silently discards the loser's edits and is clock-drift sensitive. Fine for latest-value fields, bad for rich content.
Q68. [Medium] (Theory) Why is a version counter a better conflict detector than timestamps?
Hint
Clock-free.
Solution
It's clock-independent: the server compares the client's base version against its current version; mismatch = conflict. No device clocks involved (optimistic concurrency, like ETag/If-Match).
Q69. [Medium] (Theory) What does server-authoritative versioning guarantee?
Hint
No silent clobber.
Solution
The server accepts a change only if it was based on the current version; otherwise it rejects and returns its state for the client to rebase. So updates can never silently clobber each other on the server.
Q70. [Medium] (Coding) Field-merge: base {T,B}, local {T,B2}, remote {T2,B}. Result?
Hint
Take each changed side.
Solution
{title: T2, body: B2} — only remote changed the title, only local changed the body, so keep both (no loss).
Q71. [Medium] (Theory) Why does field-level merge need the base version?
Hint
3-way.
Solution
To know which side actually changed each field (compare each side to the base — a 3-way merge). Without the base you can't distinguish a change from a non-change.
Q72. [Advanced] (Theory) When do you need version vectors instead of a counter?
Hint
Many replicas.
Solution
When many independent replicas edit offline and you must distinguish concurrent edits from causally ordered ones — a single counter can't; version vectors (per-replica counters) can.
Q73. [Advanced] (Theory) What are CRDTs and when are they worth it?
Hint
Auto-merge.
Solution
Data types whose concurrent edits always merge deterministically by mathematical properties — ideal for real-time collaborative structures (shared text/lists). Heavyweight; use a library/framework, not for simple records.
Q74. [Medium] (Theory) Default policy for delete-vs-edit, and why?
Hint
Don't lose content.
Solution
Edit wins (resurrect) for user content — losing freshly written content to a remote delete is the worse surprise. Unless the domain treats deletes as final.
Q75. [Medium] (Theory) What's the "keep both / conflicted copy" strategy for?
Hint
Never lose.
Solution
High-value content where silent resolution is unacceptable: keep both versions (e.g. a "conflicted copy") and let the user decide — never lose data.
Q76. [Advanced] (Theory) Give a sound practical strategy for FieldNotes.
Hint
Detect + merge + resurrect + copy.
Solution
Detect with a server version counter; resolve with field-level merge off the base (per-field LWW fallback); delete-vs-edit = edit wins; keep a conflicted copy for irreconcilable high-value cases; then mark dirty and re-push. Pick the simplest strategy that doesn't lose data users care about — explicitly.
Section I — Optimistic UI & sync status (Q77–85)
Q77. [Basic] (Theory) What is optimistic UI and why is it free here?
Hint
Reflects local DB.
Solution
Updating the UI immediately, assuming success. It's free because the UI reflects the instantly-written local DB (SSoT) — the screen updates before/independent of the server.
Q78. [Basic] (Theory) The three per-note sync states and their signals?
Hint
dirty/error/clean.
Solution
Pending (isDirty), failed (syncError != null), synced (clean). Derived from sync metadata.
Q79. [Medium] (Theory) Why derive sync status instead of storing it?
Hint
Consistency.
Solution
Deriving keeps status always consistent with the real metadata; a separate stored field could drift out of sync.
Q80. [Medium] (Coding) Why does the per-note badge auto-update?
Hint
Reactive row.
Solution
The note comes from a reactive stream; when the engine flips isDirty/syncError, the row changes, the stream re-emits, and the badge rebuilds with the new derived status.
Q81. [Medium] (Theory) Transient vs permanent failure in the UI?
Hint
Quiet vs loud.
Solution
Transient: stay pending, retry silently, no alarm. Permanent: set syncError, show failed with an actionable retry/fix.
Q82. [Medium] (Theory) Why should "offline with pending" messaging be calm?
Hint
Normal & safe.
Solution
Pending changes are normal and safe in offline-first (saved locally, sync later); presenting them as errors undermines trust in a working system.
Q83. [Advanced] (Theory) How does offline-first change rollback vs classic optimistic UI?
Hint
Flag, don't delete.
Solution
The local DB is authoritative for existence, so a rejected push doesn't undo the local data — it's marked failed until resolved. Rollback applies only to ephemeral server-authoritative actions; for user content you flag + let the user fix, never silently delete.
Q84. [Basic] (Coding) What's the derived status when isDirty=false and syncError=null?
Hint
Clean.
Solution
Synced — clean and matching the server.
Q85. [Advanced] (Theory) Why is a global "pending count" useful and how is it computed?
Hint
Count dirty.
Solution
It gives a single "up to date?" signal. Compute it reactively as a count of isDirty = true rows (a watched aggregate query), shown as "N change(s) to sync"/"all changes saved".
Section J — Background sync (Q86–93)
Q86. [Basic] (Theory) What is background sync and why best-effort?
Hint
OS-scheduled.
Solution
Running the engine while the app is closed, via workmanager (WorkManager/BGTaskScheduler). Best-effort because the OS decides if/when it runs, to protect battery.
Q87. [Basic] (Theory) Why re-initialize the DB/engine inside the background task?
Hint
Fresh isolate.
Solution
Background tasks run in a separate isolate with its own memory — the app's providers/singletons/open DB don't exist there, so you build them fresh (and close the DB after).
Q88. [Medium] (Theory) What does @pragma('vm:entry-point') do?
Hint
Survive tree-shaking.
Solution
Marks the dispatcher as a VM entry point so it isn't tree-shaken and can be invoked headlessly by the OS to start the background isolate.
Q89. [Medium] (Theory) Why a network constraint and an in-task reachability check?
Hint
Connected ≠ reachable.
Solution
The OS constraint avoids waking with no network; the in-task reachability probe handles "connected but not reachable" (captive portal/downtime) before attempting sync.
Q90. [Medium] (Theory) Why is foreground sync the guarantee and background the bonus?
Hint
Reliable vs OS-controlled.
Solution
Foreground triggers (start/resume/reconnect/manual) fire reliably when the user engages; background scheduling is OS-controlled and may not run. Guarantee freshness via foreground; use background to opportunistically improve it.
Q91. [Basic] (Theory) Android's minimum periodic interval?
Hint
~15.
Solution
About 15 minutes (a minimum/hint, not a guarantee; iOS is even less predictable).
Q92. [Advanced] (Theory) Why is idempotency essential for background sync specifically?
Hint
Overlap + retry.
Solution
Background and foreground syncs can overlap, and the OS may retry a "failed" task that half-succeeded. Idempotent ops (UUID upsert push, upsert/delete-by-id pull with transactional cursor) make repeated/concurrent syncNow() safe by construction.
Q93. [Advanced] (Theory) On iOS, how do you keep a rarely-reopening user's data fresh?
Hint
Sync on resume.
Solution
Make foreground sync on app launch/resume the reliable floor (always syncNow() on open), keep the outbox durable so nothing is lost, and treat background runs as a bonus. They're fully synced the moment they reopen.
Section K — Testing (Q94–100)
Q94. [Basic] (Theory) Why is the sync engine the most critical thing to test?
Hint
Rare + data loss.
Solution
Its bugs are rare, timing-dependent, and destroy irreplaceable data, and rarely reproduce manually. Simulating failures/conflicts/races in tests is the only reliable defense.
Q95. [Basic] (Theory) What two fakes underpin sync tests?
Hint
DB + API.
Solution
An in-memory Drift DB (NativeDatabase.memory() — real SQLite) and a stateful fake API you control to script conflicts/failures/latency/other devices.
Q96. [Medium] (Theory) Why a stateful fake API over a canned mock?
Hint
Behavior over calls.
Solution
Sync depends on server behavior across calls (version checks, remembered state, deltas/deletions). A stateful fake models that, enabling real multi-step scenarios; a canned mock can't.
Q97. [Medium] (Coding) How do you deterministically reproduce the dirty-during-push race in a test?
Hint
Inject latency.
Solution
Add latency to the fake's pushNote, start the push, edit the row before it resolves (bumping its version), await, then assert the row is still dirty (compare-and-clear preserved the edit).
Q98. [Medium] (Theory) Which test most directly guards against silent data loss?
Hint
Dirty not clobbered.
Solution
"Pull does not clobber a dirty local row": when a row is dirty and the server also changed it, pull must preserve the local edit (conflict-resolve, not overwrite).
Q99. [Advanced] (Theory) What does a two-device convergence test prove?
Hint
Eventual consistency.
Solution
That two independent devices sharing a server converge to identical state after syncing — the architecture's eventual-consistency promise, validating that push/pull/conflict/idempotency compose correctly end to end.
Q100. [Advanced] (Theory) Why test the oldest→newest migration in offline-first?
Hint
Irreplaceable data.
Solution
Migrations transform the local SSoT, which may hold un-synced data existing nowhere else; a broken migration causes irreversible loss. Testing the long jump (Drift schema verifier) proves existing rows survive with correct defaults across every upgrade path.
Coding Mini-Exercises
Ten larger problems. Try each before opening the solution. Exercise 10 is a capstone.
Exercise 1 — Sync-ready table. Write the Drift Notes table with all sync metadata.
Show solution
class Notes extends Table {
TextColumn get id => text()();
TextColumn get title => text().withDefault(const Constant(''))();
TextColumn get body => text().withDefault(const Constant(''))();
DateTimeColumn get updatedAt => dateTime()();
IntColumn get version => integer().withDefault(const Constant(0))();
BoolColumn get isDirty => boolean().withDefault(const Constant(true))();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
TextColumn get syncError => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
Exercise 2 — Local-first create. Write createNote that's instant and sync-ready.
Show solution
Future<Note> createNote({String title = '', String body = ''}) =>
db.into(db.notes).insertReturning(NotesCompanion.insert(
id: const Uuid().v4(),
title: title, body: body,
updatedAt: DateTime.now().toUtc(),
)); // isDirty defaults true; no network
Exercise 3 — Soft delete + purge. Write the soft delete and the post-ack purge.
Show solution
// soft delete:
(update(db.notes)..where((n) => n.id.equals(id)))
.write(const NotesCompanion(isDeleted: Value(true), isDirty: Value(true)));
// after server ack of the delete:
(delete(db.notes)..where((n) => n.id.equals(id))).go();
Exercise 4 — Compare-and-clear. Implement marking a row clean only if unchanged since push.
Show solution
(update(db.notes)..where((n) =>
n.id.equals(pushed.id) & n.version.equals(pushed.version)))
.write(NotesCompanion(isDirty: const Value(false), serverVersion: Value(v)));
// 0 rows if edited mid-push → stays dirty → re-pushed
(Part 6)
Exercise 5 — Online signal. Combine connectivity + reachability into a Stream<bool>.
Show solution
Stream<bool> watchOnline() async* {
await for (final r in Connectivity().onConnectivityChanged) {
if (r.contains(ConnectivityResult.none)) { yield false; }
else { yield await reachability.canReachServer(); }
}
}
(Part 5)
Exercise 6 — Merge guard. Write _applyRemoteChange that protects dirty local rows.
Show solution
Future<void> _applyRemoteChange(RemoteNote r) async {
final local = await getById(r.id);
if (local == null) return insertClean(r);
if (!local.isDirty) return overwriteWithRemote(r);
return resolveConflict(local, r); // dirty + remote change → conflict
}
(Part 7)
Exercise 7 — Field merge. Implement a per-field pick using a base version.
Show solution
String pick(String base, String local, String remote) {
final lc = local != base, rc = remote != base;
if (lc && !rc) return local;
if (rc && !lc) return remote;
if (!lc && !rc) return base;
return remoteNewer ? remote : local; // both changed → LWW
}
(Part 8)
Exercise 8 — Background dispatcher. Write a workmanager callbackDispatcher that syncs.
Show solution
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, data) async {
final db = AppDatabase(openConnection()); // fresh isolate
final engine = SyncEngine(db: db, api: RestNoteApi());
try { await engine.syncNow(); await db.close(); return true; }
catch (_) { await db.close(); return false; }
});
}
(Part 10)
Exercise 9 — Convergence test. Sketch a test proving two devices converge.
Show solution
final server = FakeServer();
final a = engineFor(newTestDb(), server);
final b = engineFor(newTestDb(), server);
await a.repo.createNote(title: 'A');
await b.repo.createNote(title: 'B');
for (var i = 0; i < 2; i++) { await a.syncNow(); await b.syncNow(); }
expect((await a.repo.allTitles()).toSet(), (await b.repo.allTitles()).toSet());
(Part 11)
Exercise 10 — Capstone: the sync engine. Assemble a minimal but correct SyncEngine.syncNow() tying together push (with compare-and-clear + idempotency), delta pull (with merge guard + cursor), and conflict handling. Annotate which part each piece comes from.
Show solution
class SyncEngine {
SyncEngine({required this.db, required this.api});
final AppDatabase db;
final NoteApi api;
Future<void> syncNow() async {
await _push(); // Part 6: send local changes first
await _pull(); // Part 7: then bring remote changes down
}
// ── PUSH (Part 6) ──────────────────────────────────────────
Future<void> _push() async {
final dirty = await (db.select(db.notes)..where((n) => n.isDirty.equals(true))).get();
for (final note in dirty) {
try {
final res = await api.pushNote(note); // idempotent (UUID upsert)
if (res.conflict) { await _resolve(note, res.serverNote!); continue; } // Part 8
if (note.isDeleted) {
await (db.delete(db.notes)..where((n) => n.id.equals(note.id))).go(); // purge after ack
} else {
// compare-and-clear: only clear if still at pushed version (Part 6 race fix)
await (db.update(db.notes)..where((n) =>
n.id.equals(note.id) & n.version.equals(note.version)))
.write(NotesCompanion(isDirty: const Value(false), syncError: const Value(null)));
}
} on PermanentSyncException catch (e) {
await (db.update(db.notes)..where((n) => n.id.equals(note.id)))
.write(NotesCompanion(syncError: Value(e.message))); // Part 9: surface
} catch (_) {
/* transient: leave dirty, retry next sync (Part 6) */
}
}
}
// ── PULL (Part 7) ──────────────────────────────────────────
Future<void> _pull() async {
var cursor = await _readCursor();
var more = true;
while (more) {
final res = await api.pullChanges(cursor: cursor, limit: 200);
await db.transaction(() async { // atomic apply + cursor
for (final r in res.changed) {
final local = await _getById(r.id);
if (local == null) { await _insertClean(r); }
else if (!local.isDirty) { await _overwrite(r); } // safe
else { await _resolve(local, r); } // Part 8: conflict
}
for (final id in res.deletedIds) {
final local = await _getById(id);
if (local != null && !local.isDirty) {
await (db.delete(db.notes)..where((n) => n.id.equals(id))).go();
} // else delete-vs-edit → edit wins (Part 8)
}
await _writeCursor(res.nextCursor);
});
cursor = res.nextCursor; more = res.hasMore;
}
}
// ── CONFLICT (Part 8): field merge off the base, then re-push ──
Future<void> _resolve(Note local, RemoteNote remote) async {
final base = await _lastSyncedSnapshot(local.id);
await _writeFieldMerge(local, remote, base); // marks dirty → re-pushed
}
}
How it maps to the series: client UUIDs + upsert give idempotent push (Part 6); compare-and-clear fixes the dirty-during-push race (Part 6); the merge guard (!local.isDirty) protects offline edits (Part 7); the transactional cursor makes pull crash-safe (Part 7); field merge + edit-wins resolve conflicts (Part 8); permanent errors set syncError for the UI (Part 9); and the whole syncNow is idempotent, so connectivity and background triggers can call it freely. The local DB stays the SSoT, so the reactive UI updates for free, and it's all testable with an in-memory DB + fake API.
If you can write and defend this syncNow, you've mastered offline-first.
You made it
A hundred questions, ten exercises, and a from-scratch sync engine. If you worked them honestly, you can design and build offline-first Flutter apps end to end:
- Foundations → data modeling → migrations → repository/reactive UI
- connectivity → outbox push → delta pull → conflict resolution
- optimistic UI → background sync → testing
Pair this with the Flutter State Management, Internals, and Riverpod series, and you can architect a production Flutter app that works anywhere — signal or not. Now go build FieldNotes for real. 🚀