An offline-first Flutter state management blueprint for production apps: use SQLite transactions and an operation log, make server writes idempotent, and verify retries, conflicts, and platform scheduling.
Flutter State Management: Build an Offline-First Sync Engine
Flutter state management: make the local database authoritative
In flutter mobile app development, treat the local database—not an HTTP response and not an in-memory provider—as the rendering authority. A screen should subscribe to a query such as Drift's watch(); every user intent first changes that query's rows in one SQLite transaction. This removes the common split-brain failure where a Riverpod state object says a todo is complete while the persisted row still says it is open after process death.
final todosProvider = StreamProvider.autoDispose<List<Todo>>((ref) {
return ref.watch(todoRepositoryProvider).watchTodos();
});
class TodoRepository {
TodoRepository(this.db);
final AppDatabase db;
Stream<List<Todo>> watchTodos() =>
(db.select(db.todos)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.watch();
Future<void> setDone(String todoId, bool done) async {
final now = DateTime.now().toUtc();
final operationId = const Uuid().v4();
await db.transaction(() async {
await (db.update(db.todos)..where((t) => t.id.equals(todoId))).write(
TodosCompanion(done: Value(done), updatedAt: Value(now)),
);
await db.into(db.pendingOperations).insert(
PendingOperationsCompanion.insert(
id: operationId,
entityId: todoId,
kind: 'todo.setDone',
payloadJson: jsonEncode({'done': done, 'updatedAt': now.toIso8601String()}),
createdAt: now,
),
);
});
}
}The transaction is the important part of this flutter state management design. If the app is killed after updating todos but before inserting pending_operations, the server can never learn about the change. If the operation is inserted first but the entity update fails, a later sync can overwrite a value the UI never showed. SQLite commits both statements together, and Drift's stream emits after the committed state is visible.
Flutter mobile app development: model sync as an operation log
Create an append-only operation log rather than a single dirty = 1 flag. A dirty flag loses intent when a user toggles a value three times offline; an operation record preserves an idempotency key, payload, retry count, and the time at which it may be retried. With Drift, put this schema in a migration and index the exact queue predicate used by the synchronizer.
CREATE TABLE pending_operations (
id TEXT PRIMARY KEY, -- client-generated idempotency key
entity_id TEXT NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
retry_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE INDEX pending_operations_ready_idx
ON pending_operations(status, retry_at, created_at);Send id as an Idempotency-Key header or as a field in each batch item, and require the API to store processed keys under a unique database constraint. A client-side primary key alone does not provide idempotency: a timeout can occur after the server commits but before the client receives the response. On retry, the server must return the original acknowledgement for that operation ID instead of applying a second mutation.
Preserve order per entity, not necessarily for the whole queue. For example, todo.setDone(false) must not overtake an earlier todo.rename if the API applies versioned patches, while unrelated todo IDs can be batched together. A practical query selects ready rows ordered by created_at, then the batch builder keeps only the earliest unsent operation for each entity_id. This avoids a subtle offline bug where a later update reaches the server first and is rejected as stale.
Cross platform mobile development: handle retries and OS scheduling
Use a real request result to decide queue state; do not use connectivity_plus as proof that the internet or your API is reachable. It reports network interfaces, so a captive portal can still produce Wi-Fi connectivity. Attempt the bounded HTTP request with Dio, acknowledge only operation IDs explicitly returned by the server, and leave timeouts, DNS failures, and 5xx responses eligible for retry.
Future<void> syncBatch(List<PendingOperation> batch) async {
try {
final response = await dio.post(
'/sync',
data: {'operations': batch.map((op) => op.toWire()).toList()},
options: Options(sendTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15)),
);
final accepted = Set<String>.from(response.data['acceptedIds'] as List);
await db.transaction(() async {
for (final op in batch) {
if (accepted.contains(op.id)) {
await markAcknowledged(op.id);
} else {
await scheduleRetry(op.id, op.attempts + 1);
}
}
});
} on DioException {
for (final op in batch) {
await scheduleRetry(op.id, op.attempts + 1);
}
}
}
Duration retryDelay(int attempts, Random random) {
final ceilingMs = min(300000, 1000 * (1 << min(attempts, 8)));
return Duration(milliseconds: random.nextInt(ceilingMs + 1)); // full jitter
}For cross platform mobile development, run the same sync entry point on app resume and after a successful local mutation; treat background execution as an opportunistic extra. The Flutter workmanager package can bridge Android WorkManager and iOS BackgroundTasks, but iOS scheduling is not a delivery guarantee and tasks can be delayed or skipped. Make the foreground path drain the queue, cap a batch by both count and payload bytes (for example, 50 operations or 256 KB), and persist retry_at before returning from a background callback.
Classify 401 and validation errors separately from transport failures. Retrying a malformed payload forever burns battery and hides the issue. Store a terminal rejected status with the server error code, expose it in a support-only diagnostics screen, and keep the local entity visible until product policy decides whether to roll it back or request user resolution.
Test Flutter state management against crashes and conflicts
Write repository tests around failure boundaries rather than only widget tests. With drift_dev and an in-memory SQLite executor, simulate a thrown exception between logical steps and assert that the transaction leaves either both the entity update and operation row, or neither. The test below protects the invariant that makes optimistic UI recoverable after a restart.
test('a local mutation creates exactly one durable sync intent', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final repo = TodoRepository(db);
await db.into(db.todos).insert(TodosCompanion.insert(id: 't1', title: 'Read'));
await repo.setDone('t1', true);
final todo = await (db.select(db.todos)..where((t) => t.id.equals('t1'))).getSingle();
final ops = await db.select(db.pendingOperations).get();
expect(todo.done, isTrue);
expect(ops, hasLength(1));
expect(jsonDecode(ops.single.payloadJson)['done'], isTrue);
});Test server conflict policy with explicit versions, not timestamps from device clocks. Include a server revision such as baseRevision: 12 in each operation; make the API return 409 with the canonical entity when revision 12 is no longer current. For a scalar checkbox, last-write-wins may be acceptable; for ordered lists or collaborative text, use a merge-aware model such as CRDTs or server-side operation transforms. The expensive mistake is silently applying last-write-wins to a domain where losing an edit has business consequences.
Measure queue health in addition to test assertions. Run EXPLAIN QUERY PLAN SELECT id FROM pending_operations WHERE status = 'pending' AND retry_at <= ? ORDER BY created_at LIMIT 50; on a representative database and verify that SQLite uses pending_operations_ready_idx. Record queue depth, oldest-operation age, acknowledgement latency, and rejected-operation count through OpenTelemetry or Sentry breadcrumbs. Compare these values before and after changing batch size or retry logic; a lower request count is not a win if the oldest queued edit now waits longer.
What flutter training should teach beyond a flutter course demo
A useful flutter training exercise is to put the device in airplane mode, create three edits, kill the process immediately after the third tap, restart, then restore connectivity and inspect the server's idempotency table. A flutter course project should fail its acceptance test if it only keeps an optimistic value in a provider and cannot prove that the operation survives process death.
The dart programming language detail worth enforcing is typed decoding at the storage boundary. Do not pass Map<String, dynamic> from Dio through providers into widgets; generate DTO parsing with json_serializable or map to a domain type immediately. For large sync responses, parse a sendable string in an isolate, but keep database ownership on its original isolate because most SQLite connections are not safely shareable across isolates.
// parseTodos is top-level so Isolate.run can execute it independently.
List<TodoDto> parseTodos(String body) {
final items = jsonDecode(body) as List<dynamic>;
return items
.map((item) => TodoDto.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
final decoded = await Isolate.run(() => parseTodos(response.body));
await repository.applyServerSnapshot(decoded); // transaction on DB-owning isolateAutomate the durability checks in CI with flutter test, an integration_test suite that toggles connectivity through a fake transport, and dart run build_runner build --delete-conflicting-outputs when generated Drift or JSON code changes. This catches schema/code-generation drift before an app build ships with a migration that cannot read an existing operation queue.
Related Course
Related YTUSEM Program
Flutter Program (Yildiz Technical University, Istanbul - Continuing Education Center)
Frequently Asked Questions
Which Flutter state management approach works best for offline-first data?
Use Riverpod, Bloc, or another UI-state tool to consume a stream from a local database; do not use it as the sole persistence layer. Put mutations in a repository transaction that updates domain rows and inserts an operation-log row together. Drift's query watchers then rebuild UI from durable state after a restart.
How do I make cross platform mobile development sync reliable on iOS and Android?
Invoke sync after local writes and on app resume, then optionally schedule WorkManager/BackgroundTasks through workmanager. Persist retries in SQLite because iOS may not run a scheduled task. Use actual HTTP outcomes with timeout limits; network-interface status from connectivity_plus is not an API reachability check.
How should the Dart programming language handle large JSON sync payloads?
Decode into generated typed DTOs at the boundary with json_serializable, and use Isolate.run for a large response that causes visible frame contention. Pass only sendable data such as String or primitive collections to the isolate, then apply the decoded entities through a transaction on the isolate that owns the database connection.
What should a Flutter course include for testing offline synchronization?
Include deterministic tests for timeout-after-server-commit, app termination after a local transaction, duplicate delivery of an operation ID, and a 409 revision conflict. Assert database rows and pending-operation status directly, then run an integration_test with a fake HTTP transport rather than relying on manual airplane-mode checks.
AI / LLM Discovery
This article is part of Opendart Akademi's Flutter training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.


