Flutter Offline-First Architecture and Data Synchronization
Architect resilient Flutter applications using offline-first principles: transactional outbox queues, reactive local persistence, conflict resolution, and background sync.

Flutter Offline-First Architecture and Data Synchronization
Mobile applications operate in an inherently hostile networking environment. Cellular towers hand off signals unpredictably, devices pass through subterranean transit tunnels, field workers enter steel-reinforced basements, and public Wi-Fi networks present deceptive captive portal logins. When an application treats network access as a prerequisite for interactive functionality, the user experience deteriorates rapidly: blocking loading spinners appear on screen, interactions freeze, background requests time out, and unsaved form data vanishes into socket errors.
Building resilient mobile software requires abandoning the illusion of ubiquitous connectivity. Instead of treating local storage as an ephemeral cache of remote server state, production applications adopt an offline-first architecture. In this paradigm, the local device database serves as the authoritative Single Source of Truth (SSOT) for the user interface, while remote network connections function as an asynchronous, best-effort synchronization mechanism.
This guide explores the architectural patterns, storage engines, and synchronization protocols required to build robust offline-first Flutter applications. We examine local persistence trade-offs, the transactional mutation outbox pattern, conflict resolution mechanics, deletion semantics, connectivity probing, and complete, production-grade Dart 3 implementations.
The Offline-First Paradigm Shift
The difference between an application that merely caches network responses and one built from the ground up as offline-first lies in the direction and ownership of data flow.
Online-First with Caching: The Brittle Illusion
In traditional "online-first" architectures, every user interaction triggers a direct remote HTTP request. The mobile client acts as an interactive terminal:
[User Action] ──> [HTTP Request] ──> [Network / Cloud API]
│
(Network Failure)
│
▼
[Error Alert / Blocked UI]
Caching in this model is secondary and reactive:
- The app stores recent HTTP responses in memory or a key-value store to speed up repeated queries.
- Write operations (POST, PUT, DELETE) bypass the cache entirely and require synchronous remote confirmation before updating the user interface.
- If connectivity drops mid-operation, the UI displays a retry dialog or leaves the user stuck behind a modal spinner.
- If the operating system terminates the app while waiting for a response, uncommitted user inputs are lost permanently.
This architecture breaks down under real-world conditions. High latency causes perceived UI lag, intermittent packet loss triggers repetitive failed attempts, and offline users cannot interact meaningfully with their data.
The Offline-First Architecture: Local Storage as the Single Source of Truth
An offline-first architecture inverts this dependency. The user interface never communicates directly with the network for core domain operations. Instead, the UI reads exclusively from and writes exclusively to the local embedded database:
[User Action]
│
▼
[Atomic Local Transaction]
├── Write Domain Entity (sync_status = 'pending')
└── Write Mutation Outbox Record
│
▼
[Local Database (Authoritative SSOT)]
│ ▲
▼ (Reactive Stream) │ (Async Delta Sync)
[Flutter UI] │
[Background Sync Engine]
│
▼ (HTTP / Idempotent)
[Remote API Gateway]
Under this model:
- Zero Latency Reads: Screens render immediately from local flash storage using reactive database streams (e.g., SQLite via Drift). The UI does not display blocking page spinners on launch.
- Optimistic, Durable Writes: User mutations commit to the local database within a local ACID transaction. The entity state updates immediately on screen with a pending synchronization badge.
- Decoupled Synchronization: An independent background synchronization engine reads queued mutations from a persistent outbox, applies exponential backoff with full jitter, and reconciles state with remote servers when network bandwidth is genuinely available.
- Resilience to Termination: Because changes are persisted to disk before any network transmission begins, process termination, battery exhaustion, or sudden airplane mode transitions cause zero data loss.
Architectural Comparison: Online-First vs. Offline-First
| Architectural Dimension | Online-First with Caching | Offline-First Architecture | | :--- | :--- | :--- | | Primary Source of Truth | Remote backend database | Local embedded database on the client | | User Mutation Latency | Bounded by network round-trip time (200ms–10,000ms+) | Bounded by local disk I/O (typically 1ms–10ms) | | UI Data Flow | Request-response cycle via HTTP client | Unidirectional reactive streams observing local tables | | Disconnected Behavior | Degraded read-only mode or modal error dialogs | Full read and write capabilities; seamless offline usage | | Failure Domain | Network timeouts block the user directly | Network failures are isolated to background sync workers | | Data Loss Risk | High if connectivity drops before server ACK | Near zero for committed local transactions | | Engineering Complexity | Low client complexity, high network reliance | Higher client architectural discipline (outbox, conflicts) |
Storage Engine Topologies and Mobile Persistence Trade-Offs
Choosing the underlying persistence engine dictates an application's query flexibility, relational integrity, schema migration safety, and stream reactivity. Flutter developers have three primary local storage categories: relational engines, document/key-value engines, and hardware-backed keystores.
Relational Engines: SQLite via Drift
For domain models characterized by structured relationships, referential constraints, and complex querying requirements, relational storage backed by SQLite remains the standard for production mobile development. In the Flutter ecosystem, Drift (formerly Moor) provides a type-safe, reactive Dart abstraction over SQLite.
Key advantages of SQLite with Drift:
- ACID Guarantees: Mutations and outbox queue entries commit within atomic transactions (
BEGIN TRANSACTION ... COMMIT). If a write fails midway, the entire operation rolls back, preventing corrupt partial state. - Referential Integrity: Foreign key constraints (
ON DELETE CASCADE,RESTRICT) enforce business rules directly at the storage engine level, preventing orphaned child records. - Reactive Stream Queries: Drift monitors table changes using internal update hooks. Whenever an entity or outbox row is modified, Drift automatically re-executes active query streams and pushes fresh snapshots to listening Flutter widgets.
- Deterministic Schema Migrations: Drift provides an explicit
MigrationStrategywith incrementalschemaVersioncheckpoints, enabling controlled column additions, table transformations, and index maintenance across app releases.
Document and Key-Value Stores: Hive and Isar
Document-oriented stores such as Hive and Isar store denormalized records or binary-serialized objects directly on disk.
Characteristics and trade-offs:
- Raw Sequential Throughput: By eliminating SQL parsing and relational query planning, key-value stores provide rapid key-based reads and bulk writes for flat payloads.
- Absence of Foreign Key Constraints: Relationships between collections must be managed manually in Dart application code. If a parent entity is deleted, child records remain unless explicitly cleaned up.
- Complex Query Limitations: Multi-table joins, subqueries, and aggregation queries are either unsupported or require in-memory filtering of entire collections, which can cause significant memory pressure on large datasets.
- Schema Evolution Fragility: Schemaless or binary-serialized formats require rigorous type-adapter versioning. An unhandled field type change can cause runtime deserialization crashes across app upgrades.
Use document stores for simple configuration settings, unstructured telemetry logs, or transient response payloads. For core domain models and transactional synchronization queues, relational engines provide essential durability guarantees.
Hardware-Backed Keystores vs. Application Databases
A common security anti-pattern in mobile development is attempting to store domain databases or operational synchronization queues inside platform keystores (iOS Keychain or Android Keystore via flutter_secure_storage).
Hardware keystores are designed for tiny cryptographic secrets:
- Storage Constraints: iOS Keychain items and Android Keystore entries are strictly constrained in size (typically limited to a few kilobytes). Attempting to store tabular datasets or mutation queues triggers platform quota errors.
- IPC Overhead: Keystore operations require inter-process communication (IPC) with platform security daemons (
securitydon iOS,keystoredon Android). Synchronous round-trips to secure enclaves introduce severe latency overhead when executed inside tight database loops. - Lack of Querying: Keystores provide flat key-value lookups with zero indexing, sorting, or filtering capabilities.
The Production Pattern: Use flutter_secure_storage exclusively to store a 256-bit cryptographic encryption key inside the hardware-backed keystore. Pass this key at runtime to initialize an encrypted SQLite database using SQLCipher (via Drift's encrypted database bindings). This ensures all domain tables and outbox queues are encrypted at rest on the device's flash storage with negligible cryptographic overhead.
Storage Engine Topology Comparison
| Persistence Engine | Primary Paradigm | ACID Transactions | Schema Migrations | Reactive Streams | Production Use Case |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Drift (sqlite3) | Relational SQL | Full ACID compliance | Explicit migration strategy with version hooks | Native stream query emission on table writes | Core business domain entities, relational models, transactional mutation outbox |
| Hive / Isar | Key-Value / Document | Collection-level locks | Manual type-adapter migrations | Box-level change streams | Ephemeral caches, user display preferences, flat logs |
| Hardware Keystores (flutter_secure_storage) | Secure Key-Value | Platform hardware enclave | None (manual key rotation) | None | Database encryption passphrases, OAuth refresh tokens, mTLS client certificates |
Synchronization Architectures: Unidirectional Data Flow and the Transactional Outbox
Synchronization between a mobile client and a remote server comprises two distinct pipelines: downstream read synchronization (pulling changes from the server) and upstream write synchronization (pushing mutations from the device).
Downstream Read Synchronization: Reactive Delta Pulls
Downstream synchronization follows a strictly unidirectional path. The UI never queries the remote server directly; it observes local database tables. An independent background worker pulls delta updates from the backend:
[Backend Event Stream / Polling]
│
▼ (GET /sync?cursor=1719830200000)
[Sync Worker Pulls Delta]
│
▼
[Atomic SQLite Transaction]
├── INSERT OR REPLACE INTO domain_entities
└── UPDATE sync_metadata SET last_synced_cursor = ?
│
▼ (Table Change Hook Triggered)
[Drift Reactive Query Stream]
│
▼
[Flutter UI Widget]
To implement efficient delta pulls:
- High-Water Mark / Sync Cursors: The client tracks a persistent synchronization cursor (such as a server-assigned 64-bit monotonic sequence identifier or an ISO-8601 UTC timestamp).
- Paginated Delta Endpoint: The client requests updates:
GET /api/v1/sync?cursor=1719830200000&limit=100. - Atomic Local Ingestion: The returned batch of modified and deleted entities is written into SQLite inside a single transaction. Updating the cursor and inserting the entities atomically ensures that an app crash during ingestion does not result in duplicate processing or skipped records.
- Automatic UI Propagation: Because Flutter widgets bind to Drift's reactive table streams, the UI repaints automatically upon transaction commit. No manual cache-invalidation callbacks or global event buses are required.
Upstream Write Synchronization: The Transactional Outbox Pattern
Directly dispatching HTTP requests when a user taps a button introduces critical failure modes. If the network drops while the socket is open, or if the operating system terminates the app before the HTTP response arrives, the local UI and remote backend fall out of sync.
The Transactional Outbox Pattern solves this by decoupling the intent to mutate data from the actual network transmission.
The pattern operates in five distinct phases:
Phase 1: Local Mutation Commit
User edits record ──> [Atomic DB Transaction]
├── Local entity updated (sync_status = 'pending')
└── MutationOutbox entry inserted (status = 'pending')
Phase 2: Queue Drainage Initiation
Sync Engine triggers ──> Query outbox WHERE status = 'pending' ORDER BY sequence ASC
Phase 3: Network Dispatch
Sync Engine marks row 'in_flight' ──> POST /api/v1/mutations
Header: Idempotency-Key: <UUID>
Phase 4: Remote Acknowledgement
Backend returns 200/201 OK ──> [Atomic DB Transaction]
├── Local entity sync_status = 'synced'
└── Outbox entry deleted
Phase 5: Failure Recovery
Network Error / 5xx ──> Revert outbox row to 'pending', schedule backoff retry
4xx Permanent Error ──> Mark outbox row 'failed', alert dead-letter handler
Step 1: Atomic Mutation Commit
When the user modifies an entity (e.g., updating an asset inspection report), the repository executes a single SQLite transaction that performs two writes:
- Updates the domain table with the new values, setting
sync_status = 'pending'. - Inserts a new record into the
mutation_outboxtable.
Because both writes occur inside the same database transaction, it is impossible for an outbox entry to exist without the corresponding domain update, or vice versa.
Step 2: Persistent Outbox Schema
The mutation_outbox table records all necessary metadata:
id: Internal integer auto-increment or UUID primary key.idempotency_key: A cryptographically secure UUID v4 generated on the mobile client at the moment the user confirms the action.entity_type: String identifier of the domain aggregate (e.g.,'inspection','equipment_log').entity_id: Domain identifier of the target entity.operation: Enum indicating the mutation type (CREATE,UPDATE,DELETE).payload: Serialized JSON payload containing the modified fields.sequence: Monotonically increasing sequence number ensuring causal ordering across mutations.status: Lifecycle state (pending,in_flight,failed).attempt_count: Counter tracking retry iterations.last_error: Diagnostic message captured from the last failed attempt.created_at: UTC timestamp of creation.
Step 3: Sequential Drainage and Idempotency
The sync engine queries pending outbox entries ordered by sequence ASC. For each entry:
- The engine marks the row as
in_flightto prevent duplicate processing by parallel workers. - The engine issues the HTTP request, attaching the
Idempotency-Key: <idempotency_key>header. - The remote backend uses this header to deduplicate incoming requests. If the backend already processed the mutation (for example, if a previous attempt succeeded but the mobile client lost connection before receiving the response), the server returns the cached response without re-executing the operation.
Step 4: Terminal Acknowledgement
Upon receiving a successful HTTP response (200 OK, 201 Created, or 204 No Content):
- The engine deletes the outbox entry from SQLite.
- The engine updates the domain entity's
sync_statusto'synced'. - Drift's table streams notify the UI, and the pending synchronization indicator disappears.
Conflict Resolution Mechanics and Clock Drift
In distributed systems where multiple clients can modify state concurrently while offline, conflicting updates are inevitable. Designing a robust conflict resolution strategy requires understanding the limitations of device hardware.
The Fallacy of Mobile NTP Clocks
A common but catastrophic mistake in mobile offline-first architectures is relying on mobile client timestamps to implement Last-Write-Wins (LWW) conflict resolution.
Mobile device clocks cannot be trusted for distributed ordering:
- Clock Skew and Drift: Device hardware crystal oscillators drift based on temperature, battery voltage, and manufacturing variances.
- NTP Discrepancies: Mobile devices do not continuously synchronize with NTP servers. Roaming across cellular towers or switching carrier networks can cause abrupt multi-second or multi-minute time jumps.
- Manual Tampering: Users frequently change system time to bypass paywalls, extend trial software, or adjust for travel across time zones.
If Client A's clock is skewed 10 minutes into the future and writes an update, its timestamp will supersede Client B's legitimate edits made 8 minutes later. Conversely, a client with a clock running in the past will have its offline work silently overwritten and discarded by the server. Naive client-side LWW leads to silent data destruction.
Conflict Resolution Strategies
To safely resolve concurrent edits, applications select from four proven strategies based on domain requirements:
[Concurrent Edits Detected]
│
┌─────────────────────────┴─────────────────────────┐
▼ ▼
[Distinct Properties Modified?] [Same Property Modified?]
│ │
┌─────────┴─────────┐ ┌─────────┴─────────┐
▼ ▼ ▼ ▼
[Field-Level [Automatic Merge [Optimistic Concurrency] [Interactive 3-Way Diff]
Merging] Commits] Server rejects with 409 User selects winning
Client pulls fresh base field in Flutter dialog
1. Field-Level Merging (Granular Diffs)
Instead of replacing entire entity records on the server, mutations specify only the individual fields modified by the user.
- Scenario: Inspector Alice edits the
equipment_statusfield on her mobile device while offline. Simultaneously, Dispatcher Bob updates theassigned_technicianfield on the web dashboard. - Resolution: Because Alice and Bob touched mutually exclusive fields, the server merges both updates cleanly. Neither user's contribution is overwritten.
2. Optimistic Concurrency Control (OCC) with Revision Vectors / ETags
When modifications conflict on the exact same scalar attribute, the server uses Optimistic Concurrency Control:
- Each entity possesses a server-managed monotonic
revisionnumber or cryptographicETaghash (e.g.,revision: 4). - When the mobile client downloads an entity, it records the
base_revision. - When pushing an update, the client transmits this base revision via an
If-Match: "rev-4"header or within the mutation payload. - If the server has already advanced to
revision: 5, the server rejects the request with HTTP409 Conflictand returns the latest server representation. - The mobile sync engine receives the 409 status, pauses outbox processing for that entity, and initiates a conflict reconciliation workflow.
3. Deterministic Business Commutativity (CRDTs)
For specific data structures—such as counters, additive sets, or append-only activity logs—operations can be designed as mathematically commutative. Whether Operation A arrives before Operation B or vice versa, the final resolved state is identical. Conflict-free Replicated Data Types (CRDTs) provide mathematical convergence without requiring server locks.
4. Interactive Three-Way Reconciliation
When automated resolution cannot determine business intent (for example, two engineers submit conflicting structural safety ratings for the same component), the application must defer to human judgment.
- The mobile application preserves the local pending mutation in a paused state.
- The UI presents a three-way diff dialog comparing:
- Base Version: The entity state at the time the local user began editing.
- Remote Version: The current server state updated by the remote collaborator.
- Local Version: The pending offline edits made on the device.
- The user reviews the discrepancies, chooses the winning values per field, and confirms. The app constructs a new reconciliation mutation that supersedes both branches.
Strategy Trade-Off Comparison
| Strategy | Mechanism | Clock Skew Sensitivity | Data Loss Risk | Implementation Complexity | Best Domain Fit |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Field-Level Merging | Delta patches per attribute | Zero (evaluates property divergence) | None for orthogonal fields; requires fallback for collisions | Moderate | Multi-user forms with independent sections |
| Optimistic Concurrency (OCC) | Server revision checks (If-Match) | Zero (relies on server revision counter) | Zero (rejects conflicting writes) | Moderate | Financial ledgers, inventory counts, critical records |
| CRDTs | Commutative mathematical structures | Zero | Zero (convergent by design) | High | Collaborative text, counters, shopping cart additions |
| Interactive 3-Way Diff | Visual diff presentation to user | Zero | Zero (explicit user selection) | High | Complex documents, medical charts, safety inspections |
Deletion Semantics: Soft Deletes and the Tombstone Pattern
Deleting data in an offline-first architecture requires specialized care. Naive hard deletes introduce one of the most pervasive bugs in mobile synchronization: the resurrection bug.
The Resurrection Bug
Consider an application that executes hard deletes locally:
- While offline, a user deletes an inspection item:
DELETE FROM inspections WHERE id = 'ins-42';. - The local database immediately purges the row. The UI removes the item from the screen.
- The device reconnects to Wi-Fi.
- The background sync worker initiates downstream read synchronization:
GET /api/v1/sync?cursor=1719830000000. - The backend returns a delta batch containing active records. Because the backend has not yet received a delete command, entity
'ins-42'is present in the server's dataset. - The client sync engine processes the incoming batch, sees that
'ins-42'does not exist in SQLite, and issues anINSERT INTO inspections .... - The entity resurrects on the user's screen.
[Offline: User Deletes Item]
SQLite: DELETE FROM items WHERE id = 'ins-42';
│
(Device Reconnects)
│
▼
[Sync Engine: GET /sync?cursor=...]
Server returns: { id: 'ins-42', name: 'Pump Valve' }
│
▼
[Sync Engine Ingestion]
SQLite: INSERT INTO items (id, name) VALUES ('ins-42', 'Pump Valve');
│
▼
[RESURRECTION BUG: Deleted Item Reappears in UI]
Tombstone Lifecycle and Safe Garbage Collection
To permanently eliminate resurrection bugs, offline-first architectures implement the Tombstone Pattern using soft deletes.
1. Entity Schema with Tombstone Metadata
Every synchronizable domain table includes tombstone columns:
ALTER TABLE inspections ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE inspections ADD COLUMN deleted_at INTEGER; -- UTC epoch milliseconds
ALTER TABLE inspections ADD COLUMN sync_status TEXT NOT NULL DEFAULT 'synced';
2. Local Deletion Execution
When the user deletes an item:
- The app does not issue a SQL
DELETE. - Instead, it updates the record within a transaction:
UPDATE inspections SET is_deleted = 1, deleted_at = 1719830250000, sync_status = 'pending_deletion' WHERE id = 'ins-42'; - Atomically inserts a
DELETEmutation into themutation_outboxtable.
3. View Filtering
All active UI queries automatically filter out soft-deleted records:
SELECT * FROM inspections WHERE is_deleted = 0 ORDER BY created_at DESC;
The item disappears from the user's view instantly.
4. Upstream Deletion Propagation
The sync engine picks up the DELETE mutation from the outbox and dispatches an HTTP DELETE /api/v1/inspections/ins-42 with the entity's idempotency key and base revision.
5. Backend Tombstone Ingestion and Garbage Collection
- The backend marks the entity as deleted in its database and updates its downstream sync change-log with a tombstone entry:
{ id: 'ins-42', deleted: true, cursor: 1719830260000 }. - Other mobile clients pulling downstream deltas encounter the tombstone record and mark their local copies as soft-deleted.
- Local Garbage Collection: Once the backend acknowledges the deletion and the client's local high-water sync cursor advances past the deletion event, a background maintenance task can safely execute a hard purge of tombstones older than the retention window (e.g., 30 days) to reclaim disk space.
Connectivity Flapping, Captive Portals, and Backoff Jitter
Accurate connectivity detection is essential for triggering synchronization workers without draining device batteries or overwhelming backend services.
The Captive Portal Trap in Mobile Connectivity
A widespread flaw in Flutter networking code is relying solely on platform interface listeners, such as the connectivity_plus package:
// FRAGILE PATTERN: Interface availability does NOT equal Internet access
Connectivity().onConnectivityChanged.listen((List<ConnectivityResult> results) {
if (results.contains(ConnectivityResult.wifi)) {
syncEngine.drainOutbox(); // FAILS on captive portals!
}
});
connectivity_plus queries whether an operating system network interface (Wi-Fi, Cellular, Ethernet) has an assigned IP address and active link socket. However:
- Hotel and airport Wi-Fi networks assign local IP addresses immediately while blocking all external TCP/UDP traffic behind an authentication web portal.
- Underground transit systems frequently broadcast active Wi-Fi SSIDs that lack an active backhaul gateway.
- Cellular connections in fringe areas can maintain a carrier radio tower link while data packets stall entirely.
Triggering synchronization based purely on interface status causes the outbox engine to loop continuously through failing HTTP requests, generating CPU wake-locks, wasting battery power, and filling logs with socket exceptions.
Multi-Tier Connectivity Probing
Production applications combine interface change listeners with an active, lightweight network probe:
[OS Interface Change Event] (e.g., Wi-Fi link detected)
│
▼
[Active Probe: Lightweight HTTP HEAD / GET]
Target: Dedicated health endpoint (e.g., https://api.openstair.com/generate_204)
Timeout: 3,000ms strict deadline
│
┌──────┴──────┐
▼ ▼
[204 OK] [Timeout / 302 Redirect / Socket Error]
│ │
│ ▼
│ [Captive Portal or Dead Gateway Detected]
│ Do NOT start sync worker; suppress retries
▼
[Genuine Internet Verified]
Trigger Outbox Queue Drainage
The health check target must:
- Return a lightweight status (e.g., HTTP
204 No Contentor{ "status": "ok" }). - Be served with
Cache-Control: no-storeto prevent caching by intermediate proxies. - Enforce a strict network timeout (typically 3,000 milliseconds) so the probe fails fast on stalled links.
Exponential Backoff with Full Jitter
When a network outage ends—such as a subway train emerging from a tunnel into a station—hundreds of passenger devices regain connectivity simultaneously. If every client executes standard deterministic exponential backoff (), they retry at identical intervals:
Attempt 1: 1.0s
Attempt 2: 2.0s
Attempt 3: 4.0s
Attempt 4: 8.0s
This synchronization creates periodic, destructive traffic surges known as thundering herds. These concentrated spikes saturate backend load balancers, trigger connection pool exhaustion, and force database thread throttling.
To prevent thundering herds, mobile clients implement Exponential Backoff with Full Jitter. Instead of waiting for the exact exponential duration, the client picks a uniform pseudo-random value between zero and the calculated exponential ceiling:
SURGE PROFILE COMPARISON:
Deterministic Backoff (Thundering Herd):
Traffic │ ▲ ▲ ▲
│ │ │ │
│ │ │ │
└────┴─────────┴─────────┴─────► Time
Full Jitter Backoff (Smooth Dispersion):
Traffic │ ▄ ▃ ▅ ▃ ▄ ▂ ▃ ▄ ▅ ▃ ▂ ▄ ▃
│ █ █ █ █ █ █ █ █ █ █ █ █ █
└──────────────────────────────► Time
By scattering retry attempts uniformly across time, backend traffic remains smooth, predictable, and resilient under recovery spikes.
Production-Ready Dart 3 Outbox Implementation
Below is a self-contained, strictly typed Dart 3 implementation demonstrating an immutable outbox entry model, an asynchronous mutex gate, and a production outbox synchronization engine with full jitter backoff and idempotency handling.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
/// Represents the execution lifecycle status of an outbox mutation.
enum OutboxStatus {
pending,
inFlight,
failed,
}
/// Represents the operational intent of the mutation.
enum OutboxOperation {
create,
update,
delete,
}
/// Immutable record representing a pending mutation queued for synchronization.
final class MutationOutboxEntry {
final String id;
final String idempotencyKey;
final String entityType;
final String entityId;
final OutboxOperation operation;
final Map<String, dynamic> payload;
final int sequence;
final OutboxStatus status;
final int attemptCount;
final String? lastError;
final DateTime createdAt;
const MutationOutboxEntry({
required this.id,
required this.idempotencyKey,
required this.entityType,
required this.entityId,
required this.operation,
required this.payload,
required this.sequence,
required this.status,
this.attemptCount = 0,
this.lastError,
required this.createdAt,
});
MutationOutboxEntry copyWith({
OutboxStatus? status,
int? attemptCount,
String? lastError,
}) {
return MutationOutboxEntry(
id: id,
idempotencyKey: idempotencyKey,
entityType: entityType,
entityId: entityId,
operation: operation,
payload: payload,
sequence: sequence,
status: status ?? this.status,
attemptCount: attemptCount ?? this.attemptCount,
lastError: lastError ?? this.lastError,
createdAt: createdAt,
);
}
}
/// A lightweight re-entrancy gate preventing concurrent sync loops across async gaps.
final class AsyncMutex {
Completer<void>? _currentLock;
bool get isLocked => _currentLock != null && !_currentLock!.isCompleted;
Future<T> runLocked<T>(Future<T> Function() action) async {
while (_currentLock != null && !_currentLock!.isCompleted) {
await _currentLock!.future;
}
final completer = Completer<void>();
_currentLock = completer;
try {
return await action();
} finally {
completer.complete();
}
}
}
/// Abstract contract for local persistent outbox operations.
abstract interface class OutboxRepository {
Future<List<MutationOutboxEntry>> fetchPendingEntries({int limit = 50});
Future<void> updateEntry(MutationOutboxEntry entry);
Future<void> deleteEntry(String id);
Future<void> markEntitySynced(String entityType, String entityId);
}
/// Polymorphic result representing remote mutation dispatch outcomes.
sealed class SyncResult {
const SyncResult();
}
final class SyncSuccess extends SyncResult {
final Map<String, dynamic>? responseData;
const SyncSuccess([this.responseData]);
}
final class SyncTransientFailure extends SyncResult {
final String reason;
const SyncTransientFailure(this.reason);
}
final class SyncPermanentFailure extends SyncResult {
final int statusCode;
final String errorPayload;
const SyncPermanentFailure(this.statusCode, this.errorPayload);
}
/// Abstract contract for the remote network transport client.
abstract interface class SyncApiClient {
Future<SyncResult> dispatchMutation({
required String idempotencyKey,
required String entityType,
required String entityId,
required OutboxOperation operation,
required Map<String, dynamic> payload,
});
}
/// Production-ready outbox synchronization engine.
final class OutboxSyncEngine {
final OutboxRepository _outboxRepo;
final SyncApiClient _apiClient;
final AsyncMutex _mutex = AsyncMutex();
final Random _random = Random();
OutboxSyncEngine({
required OutboxRepository outboxRepo,
required SyncApiClient apiClient,
}) : _outboxRepo = outboxRepo,
_apiClient = apiClient;
/// Calculates backoff delay with full jitter: random(0, min(maxDelay, baseDelay * 2^attempt))
Duration calculateJitteredBackoff({
required int attempt,
Duration baseDelay = const Duration(seconds: 1),
Duration maxDelay = const Duration(seconds: 30),
}) {
final exponentialMs = baseDelay.inMilliseconds * pow(2, attempt).toInt();
final cappedMs = min(maxDelay.inMilliseconds, exponentialMs);
final jitteredMs = _random.nextInt(cappedMs > 0 ? cappedMs : 1);
return Duration(milliseconds: jitteredMs);
}
/// Drains the pending outbox queue sequentially with mutual exclusion.
Future<int> processQueue({int batchSize = 50}) async {
return _mutex.runLocked(() async {
final entries = await _outboxRepo.fetchPendingEntries(limit: batchSize);
if (entries.isEmpty) return 0;
int synchronizedCount = 0;
for (final entry in entries) {
// Mark entry as in-flight to prevent duplicate concurrent dispatch
await _outboxRepo.updateEntry(
entry.copyWith(status: OutboxStatus.inFlight),
);
final result = await _apiClient.dispatchMutation(
idempotencyKey: entry.idempotencyKey,
entityType: entry.entityType,
entityId: entry.entityId,
operation: entry.operation,
payload: entry.payload,
);
switch (result) {
case SyncSuccess():
// 1. Mark local domain entity as synchronized
await _outboxRepo.markEntitySynced(entry.entityType, entry.entityId);
// 2. Atomically delete entry from outbox
await _outboxRepo.deleteEntry(entry.id);
synchronizedCount++;
case SyncTransientFailure(:final reason):
// Transient network error: reset to pending and preserve order
await _outboxRepo.updateEntry(
entry.copyWith(
status: OutboxStatus.pending,
attemptCount: entry.attemptCount + 1,
lastError: reason,
),
);
// Halt subsequent entries in this pass to preserve causal ordering
return synchronizedCount;
case SyncPermanentFailure(:final statusCode, :final errorPayload):
// Permanent rejection (e.g. 403 Forbidden, 422 Validation Error)
// Mark failed for dead-letter review without blocking unrelated entities
await _outboxRepo.updateEntry(
entry.copyWith(
status: OutboxStatus.failed,
attemptCount: entry.attemptCount + 1,
lastError: 'HTTP $statusCode: $errorPayload',
),
);
}
}
return synchronizedCount;
});
}
}
Architectural Details of the Implementation
- Re-Entrancy Guard via
AsyncMutex: Dart runs single-threaded code on an event loop, butawaitstatements create asynchronous suspension points. If a connectivity restoration event fires while a user-triggered sync is awaiting an HTTP response, two execution paths can interleave. TheAsyncMutexensures that only one task drains the outbox at any time, preventing duplicate network dispatches. - Exhaustive Result Matching: Using Dart 3 sealed classes (
sealed class SyncResult), the compiler enforces compile-time exhaustiveness checks in theswitchstatement, ensuring transient, permanent, and successful outcomes are always handled explicitly. - Causal Queue Ordering: When a transient network failure occurs, the engine halts the current pass and leaves remaining items queued. This preserves causality: an
UPDATEmutation will never execute on the remote server before the precedingCREATEmutation finishes. - Permanent Failure Isolation: HTTP 4xx validation or permission failures do not freeze the entire outbox queue indefinitely. The engine transitions the problematic item to
OutboxStatus.failed, isolating it for administrative reconciliation while allowing subsequent, unrelated mutations to proceed.
Production Verification Checklist
Before releasing an offline-first Flutter application to production, engineering teams should validate system behavior against this operational readiness checklist:
- [ ] ACID Persistence Boundaries: Are local domain entity updates and outbox insertions wrapped in a single SQLite transaction? Verify that failure during outbox insert cleanly rolls back the domain update.
- [ ] Unidirectional UI Observation: Does the Flutter UI layer bind exclusively to reactive local database streams (e.g. Drift watch queries) rather than depending on direct HTTP response models?
- [ ] Idempotency Propagation: Does every mutation outbox entry generate a unique client-side UUID at the moment of user creation? Is this UUID transmitted via the
Idempotency-KeyHTTP header? - [ ] Clock Skew Independence: Does conflict resolution avoid relying on client-side system timestamps? Is Optimistic Concurrency Control enforced using server-assigned monotonic revision numbers or content ETags?
- [ ] Tombstone Semantics: Are local deletions executed as soft deletes (
is_deleted = 1) until server acknowledgement occurs? Confirm that delta sync ingestion does not trigger resurrection bugs. - [ ] Active Connectivity Probing: Does network detection combine operating system link state listeners with a fast, active HTTP health probe (
204 No Content) to avoid captive portal traps? - [ ] Full Jitter Backoff: Does the retry policy apply randomized exponential backoff to eliminate thundering herd spikes during network reconnection?
- [ ] Hardware Keystore Scope: Are sensitive cryptographic keys stored in
flutter_secure_storagewhile tabular domain data resides in SQLCipher-encrypted SQLite, avoiding keystore storage quota violations? - [ ] Dead-Letter Handling: Does the outbox engine isolate permanent 4xx validation errors (
OutboxStatus.failed) so that malformed requests do not block the causal synchronization queue indefinitely? - [ ] Background Execution Budgeting: On iOS (
BGAppRefreshTask) and Android (WorkManager), does the background sync task adhere to strict operating system execution time budgets (typically 30 seconds) and terminate cleanly before OS process suspension?
Conclusion: Building Resilient Mobile Systems
Offline-first engineering is not merely an optimization for spotty connections; it represents a fundamental shift toward software reliability. By establishing the local device database as the authoritative Single Source of Truth, applications deliver instantaneous responsiveness, eliminate blocking UI spinners, and ensure that user inputs remain durable regardless of physical networking conditions.
Implementing this architecture requires deliberate discipline:
- Encapsulating mutations within the Transactional Outbox Pattern guarantees that state transitions survive process termination and network drops.
- Eliminating client-timestamp dependencies in favor of Optimistic Concurrency Control prevents catastrophic silent data loss caused by mobile clock drift.
- Adopting Tombstone Deletion Semantics eliminates resurrection bugs and maintains cross-client synchronization integrity.
- Applying Full Jitter Exponential Backoff protects backend infrastructure from thundering herd surges upon reconnection.
When network connectivity is treated as an opportunistic transport channel rather than an application prerequisite, Flutter applications achieve true production resilience—delivering a seamless, trustworthy experience whether operating in modern corporate offices, high-speed rail corridors, or disconnected industrial environments.


