UbisoftlyUbisoftlySoftware Solutions
← All InsightsMobile Engineering12 min read

Building Offline-First Flutter Apps: SQLite, Sync Queues & Conflict Resolution

Most mobile applications break the moment a device loses cellular coverage or Wi-Fi connectivity. In this architecture breakdown, we document the four-tier architectural pattern we use at Ubisoftly to build Flutter applications for iOS and Android that remain fully functional offline and synchronize deterministically when connectivity returns.

The Problem: Why Most Offline Implementations Fail in Production

When mobile software is designed with an “online-first with a cache” mentality, edge-case networking failures inevitably manifest in user-facing defects. In real-world enterprise environments—such as field inspections, warehouse logistics, aviation, and healthcare—connectivity is inherently intermittent. Poorly architected Flutter mobile applications fail in three predictable ways:

  1. Unhandled Exception Crashes: The application relies on immediate HTTP/REST request-response cycles. When sockets time out or drop packets mid-handshake, unhandled future errors bubble up, corrupting state stores and crashing the view stack.
  2. Silent Input Loss & Dead Ends: The UI displays generic network error dialogs but fails to commit user form entries into persistent local storage. When the user navigates away or the operating system terminates the background process to reclaim memory, valuable customer data is permanently destroyed.
  3. Destructive Overwrite Collisions (Last-Write-Wins Pitfall): The application stores mutations locally, but upon reconnection blindly sends bulk overwrite payloads to the backend API without validating document revision tags, destroying concurrent updates submitted by other team members.

Four-Tier Architecture Overview

Our offline-first architectural model decouples user interface execution from network availability. The Flutter UI interacts exclusively with the local database engine, treating local storage as the single source of truth for all read and write transactions.

┌─────────────────────────────────────────────────────────────────────────────┐
│                              UI LAYER (Flutter Widgets)                      │
│  • Listens to reactive SQLite streams (ValueNotifier / Bloc / Riverpod)      │
│  • Emits optimistic user actions immediately (sub-16ms render response)      │
├─────────────────────────────────────────────────────────────────────────────┤
│                         REPOSITORY LAYER (Domain Contracts)                  │
│  • Generates local UUIDv4 identifiers prior to database write               │
│  • Commits changes synchronously into SQLite transactional tables           │
│  • Enqueues serialized change payloads into the persistent sync queue       │
├─────────────────────────────────────────────────────────────────────────────┤
│                        LOCAL STORAGE ENGINE (SQLite / Drift)                │
│  • Serves as the immutable single source of truth on the physical device    │
│  • Tracks synchronization flags, mutation timestamps, and tombstone records │
├─────────────────────────────────────────────────────────────────────────────┤
│                       SYNCHRONIZATION ENGINE (Dart Isolate)                  │
│  • Runs inside a dedicated background worker isolate to prevent frame drops │
│  • Manages exponential backoff retries, batching, and conflict resolution   │
└─────────────────────────────────────────────────────────────────────────────┘

Layer 1: Deterministic SQLite Database Schema

To guarantee zero data loss and support reliable bi-directional syncing, every business entity table incorporates three synchronization metadata columns. In addition, an isolated transactional queue table logs every pending operation in chronological sequence:

-- Base Business Entity Table (e.g., Work Orders)
CREATE TABLE work_orders (
  id           TEXT PRIMARY KEY,
  client_id    TEXT NOT NULL,
  title        TEXT NOT NULL,
  status       TEXT NOT NULL DEFAULT 'draft',
  payload_json TEXT NOT NULL,
  updated_at   TEXT NOT NULL,
  
  -- Synchronization Control Metadata
  sync_status  TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'synced' | 'conflict'
  sync_queue   INTEGER NOT NULL DEFAULT 1,       -- 1 = dirty local record requiring upstream sync
  deleted_at   TEXT                              -- Soft-delete timestamp (Tombstone pattern)
);

-- Persistent Transactional Sync Queue
CREATE TABLE sync_queue (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  table_name   TEXT NOT NULL,
  record_id    TEXT NOT NULL,
  operation    TEXT NOT NULL,                   -- 'insert' | 'update' | 'delete'
  payload      TEXT NOT NULL,                   -- Normalized JSON payload for upstream ingestion
  created_at   TEXT NOT NULL,
  attempts     INTEGER NOT NULL DEFAULT 0,
  last_error   TEXT
);

-- Index for high-speed sequential queue processing
CREATE INDEX idx_sync_queue_pending ON sync_queue(created_at) WHERE attempts < 5;

The sync_status column enables the presentation layer to render subtle sync badges (e.g., green checkmark for synced, pulsing amber indicator for pending sync, or red warning icon for manual conflict resolution). Soft deletion via deleted_at guarantees that records deleted offline can still be propagated to cloud servers before their local database rows are purged.

Layer 2: Optimistic Repository Implementation

In an offline-first Flutter application, repositories must never wait on network socket connections before returning a success response to the state management layer. All write operations generate a client-side UUIDv4 and write directly to SQLite:

class WorkOrderRepository {
  final AppDatabase _db;
  final SyncQueueService _syncQueue;

  Future<WorkOrder> createWorkOrder(WorkOrder draft) async {
    // 1. Generate client-side UUIDv4 to eliminate server ID dependencies
    final entity = draft.copyWith(
      id: const Uuid().v4(),
      syncStatus: 'pending',
      syncQueue: 1,
      updatedAt: DateTime.now().toUtc().toIso8601String(),
    );

    // 2. Commit transaction to local SQLite engine
    await _db.workOrders.insert(entity);

    // 3. Register payload into the persistent sync queue
    await _syncQueue.enqueue(
      tableName: 'work_orders',
      recordId: entity.id,
      operation: 'insert',
      payload: jsonEncode(entity.toJson()),
    );

    // 4. Return immediately to the UI layer (sub-16ms response)
    return entity;
  }

  // All UI reads query the local SQLite stream
  Stream<List<WorkOrder>> watchWorkOrders() {
    return _db.workOrders.watchActiveOrders();
  }
}

Layer 3: Dedicated Background Sync Engine Isolate

Performing JSON parsing, compression, and network I/O on Flutter's main UI thread causes micro-stutters and frame drops on modern 120Hz ProMotion displays. To prevent jank, our synchronization engine executes within an isolated Dart isolate:

class SyncEngine {
  final SyncQueue _queue;
  final ApiClient _api;
  final AppDatabase _db;

  Future<void> processQueueBatch() async {
    final pendingItems = await _queue.fetchPendingBatch(limit: 25);
    if (pendingItems.isEmpty) return;

    for (final item in pendingItems) {
      try {
        final serverResponse = await _api.pushMutation(
          table: item.tableName,
          operation: item.operation,
          payload: jsonDecode(item.payload),
        );

        // Mark local record as confirmed and synced
        await _db.markRecordSynced(
          table: item.tableName,
          id: item.recordId,
          versionToken: serverResponse.versionToken,
        );

        // Remove processed item from queue
        await _queue.remove(item.id);
      } on ConflictException catch (e) {
        await _resolveConflict(item, e.serverRecord);
      } catch (e) {
        await _queue.recordFailure(item.id, error: e.toString());
      }
    }
  }
}

Layer 4: Deterministic Conflict Resolution Strategies

When multiple devices update the same data model offline, upstream synchronization must apply strict, deterministic resolution rules tailored to the criticality of each domain entity:

StrategyResolution RuleBest For
Server WinsRemote timestamp supersedes local uncommitted writes. Local cache refreshes to match authoritative server state.Inventory stock levels, global pricing catalogs, dispatch status
Client WinsLocal device mutations overwrite remote state unconditionally, forcing cloud synchronization.User profile settings, local device preferences, personal draft notes
Append / MergeBoth entries are preserved and appended chronologically using distributed UUID keys.Activity logs, audit trails, inspection checklist items, timestamped comments
Manual UI PromptFlags the entity with 'conflict' status and renders a side-by-side visual diff allowing the user to select the final state.High-value enterprise contracts, complex multi-field medical records, invoices

Key Flutter Libraries & Toolchain Recommendations

To build high-performance offline architectures without reinventing foundational storage primitives, we leverage battle-tested open-source libraries:

sqflite / sqlite3

The gold standard for relational mobile persistence with robust schema migrations and indexing support.

drift (formerly moor)

Type-safe Dart SQL queries and reactive stream bindings directly compiling to native SQLite.

connectivity_plus

Cross-platform network state change listeners for triggering queue drain events across iOS and Android.

workmanager

Native Android WorkManager and iOS BGAppRefreshTask scheduling for reliable background sync when the app is suspended.

Building a Mission-Critical Mobile App Requiring Offline Resilience?

At Ubisoftly, we architect and engineer robust, cross-platform Flutter applications for enterprise operations, logistics, and field workforce platforms across the UK, Europe, and Middle East.