Mobile Development
FlutterOffline-FirstSyncMobile ArchitectureLocal Storagen8n

Flutter Offline-First Apps: Local Storage, Sync Strategies, and Conflict Resolution

AO
Adrijan Omićević
·15 min read

# What You'll Learn#

Offline-first apps keep working when the network fails, the user is in airplane mode, or the backend is under load. That reliability translates directly into retention and revenue: Google’s research on mobile performance shows that as page load time increases from 1 to 3 seconds, bounce probability rises by 32 percent, and unreliable network behavior produces a similar frustration curve for apps.

This guide explains the core principles and gives you a reference approach for Flutter offline-first sync: local persistence choices, background sync strategies, conflict resolution patterns, and how to test the whole system under flaky network conditions.

# Offline-First Principles That Actually Matter#

Offline-first is not “cache some data.” It is a product and architecture decision: the app must behave correctly without a network, then reconcile later.

Principle 1: Local is the Source of Truth#

Reads and writes should hit local storage first. The UI should render from the local database, not from a request future.

This reduces latency to near-zero for repeated views and avoids UI jank caused by network roundtrips. If you are chasing smooth UI, also review Flutter performance optimization for consistent 60fps because offline-first apps often do more local work on the main thread if you are not careful.

Principle 2: Treat the Network as Eventually Available#

Sync must be resumable, idempotent, and safe to retry. “Send once” logic fails in real-world mobile conditions: background restrictions, IP changes, captive portals, and mid-flight app kills.

A practical target is this: any sync operation can be executed multiple times and still converge to the correct server state.

Principle 3: Make Failures Visible and Actionable#

Users will tolerate offline mode if the app is honest about it. Add clear states such as:

  • Pending changes count
  • Last sync time
  • Retry button for stuck items
  • Conflict banner when manual resolution is needed

Principle 4: Prefer Incremental Sync Over Full Refresh#

Full refresh becomes expensive fast on mobile data and battery. Incremental sync is also the foundation for reliable conflict handling because you can reason about deltas.

A common baseline is “sync since lastSyncAt” plus per-record version checks.

# Local Persistence Options in Flutter#

Your local storage is not just a cache; it is your operational database. Choose based on query patterns, relationships, and how you plan to model sync metadata.

OptionBest forProsConsTypical offline-first use
Drift (SQLite)Relational data, complex queriesMature SQL, joins, migrations, strong toolingMore boilerplate, need schema designTasks, inventory, CRM-like apps
IsarFast object storage and queriesHigh performance, simple object modelLess natural for complex relations, schema changes need planningContent apps, catalogs, local-first feeds
HiveSimple key-value and small datasetsEasy setup, lightweightNot ideal for complex queries, fewer constraintsSettings, small caches, feature flags
SharedPreferencesTiny configBuilt-in, trivialNot a database, no queryingOnboarding flags, selected account
SembastDocument storeFlexible JSON, no native depsPerformance varies, fewer advanced query featuresPrototypes, moderate datasets

For “offline-first sync” with non-trivial domain logic, Drift or Isar are typically the best starting points. Drift is often the safer choice when you need reliable constraints and transactional updates across multiple tables.

💡 Tip: If you expect merges and conflicts, model your data relationally or at least store sync metadata alongside each entity. You will need it later for debugging and resolution.

The Minimum Data You Need to Store for Sync#

Most teams under-model sync metadata and pay for it in edge cases. For each record, store at least:

FieldTypePurpose
idstringStable identifier (prefer UUID v4)
updatedAtdatetimeLast local update time for UX and merges
serverUpdatedAtdatetime nullableLast known server update time
versionint nullableOptimistic concurrency token from server
syncStatusenumClean, pending, syncing, failed, conflicted
deletedboolTombstone for soft delete and sync

Also store a durable outbox for changes that must reach the server.

# Reference Architecture for Flutter Offline-First Sync#

A reliable architecture separates responsibilities: UI reads local state, domain decides what to sync, data layer persists, sync engine coordinates.

If you are structuring a new Flutter codebase, align this with a feature-first approach as described in Flutter app architecture: Clean Architecture with feature-first.

Components and Responsibilities#

ComponentResponsibilityNotes
Local DB (Drift or Isar)Store entities, outbox, sync metadataTransactional updates matter
RepositoryExpose streams and write methodsAlways write locally first
OutboxQueue of pending mutationsDurable, ordered, idempotent
Sync EngineUpload outbox, then pull server changesRuns foreground and background
Conflict ResolverDecide merge strategyAutomatic where safe, manual where needed
API ClientNetwork calls with retriesMust support idempotency keys

Data Flow: Write Path#

  1. 1
    User edits a record.
  2. 2
    Repository writes changes to local DB in a transaction.
  3. 3
    Repository enqueues an outbox item representing the mutation.
  4. 4
    UI updates immediately from local stream.
  5. 5
    Sync engine uploads outbox when allowed.

The key is that the UI never waits for the network to confirm the edit.

Data Flow: Read Path#

  • UI listens to local DB streams.
  • Sync engine periodically pulls server changes and applies them locally.
  • Applying server changes must be safe even if local pending edits exist.

# Implementing the Outbox Pattern#

The outbox is a table or collection of “things we need to tell the server.” This is the most reliable pattern for mobile sync because it survives app restarts and flaky networks.

Outbox Schema#

ColumnExampleWhy it matters
iduuidUnique outbox item id
entityTypetaskHelps routing
entityIdtask_123Target entity
operationcreate / update / deleteMutation type
payloadJSON stringDelta or full object
createdAttimestampOrdering and debugging
attemptCount0..nBackoff decisions
statuspending/sending/failedRecoverability
idempotencyKeyuuidPrevent duplicate server writes

Drift Example: Outbox Table and Enqueue#

Dart
// Keep code blocks short and copy-pasteable.
// This snippet illustrates the core idea, not the full database file.
 
class OutboxItems extends Table {
  TextColumn get id => text()();
  TextColumn get entityType => text()();
  TextColumn get entityId => text()();
  TextColumn get operation => text()(); // create|update|delete
  TextColumn get payload => text()();   // JSON
  TextColumn get idempotencyKey => text()();
  IntColumn get attemptCount => integer().withDefault(const Constant(0))();
  TextColumn get status => text().withDefault(const Constant('pending'))();
  DateTimeColumn get createdAt => dateTime()();
 
  @override
  Set<Column> get primaryKey => {id};
}
 
Future<void> enqueueOutboxItem(OutboxItemsCompanion item) async {
  await db.into(db.outboxItems).insert(item);
}

Upload Strategy: Ordering and Coalescing#

If a user edits the same record five times offline, you usually do not want to upload five updates. Two practical strategies:

StrategyHow it worksWhen to use
Coalesce by entityKeep only the latest update per entityHigh-frequency edits like notes, forms
Preserve full historyUpload all mutations in orderWorkflows that must be audited

Coalescing can cut bandwidth dramatically. In real apps, it can reduce outbound sync calls by 50 to 90 percent for “draft-style” editing.

⚠️ Warning: Coalescing deletes needs special handling. If a record is deleted after updates, ensure the final state is a delete and earlier updates do not resurrect it during retries.

# Pull Sync: Incremental, Paginated, and Safe#

Upload-only sync is not enough. You need pull sync to get changes from other devices and server-side processes.

Prefer these endpoints server-side:

EndpointReturnsKey fields
GET /changes?since=tokenList of changed records plus a new tokenentityType, entityId, operation, data, version, serverUpdatedAt
GET /snapshot?cursor=xPaginated full datasetFor first install or forced resync

Using a change token is usually better than a raw timestamp because you avoid clock skew issues and can build deterministic pagination.

Applying Server Changes Locally#

Apply changes in a transaction and update sync metadata. When a server update arrives for a record with local pending edits, you must decide whether to:

  • defer applying the server update until local edits are uploaded
  • apply the server update to a shadow copy
  • merge at field-level

The correct choice depends on your conflict strategy, described next.

# Conflict Resolution Patterns (With When-To-Use Guidance)#

Conflicts are inevitable if users can edit on multiple devices or if server-side automations modify the same data. The goal is predictable outcomes and minimal user pain.

Pattern 1: Last-Write-Wins (LWW)#

Rule: the record with the newest serverUpdatedAt wins.

ProsConsBest for
Simple, fastSilent data loss possibleNon-critical data like “last viewed”, presence, cacheable preferences

LWW is acceptable for fields where losing an update is not harmful. For business-critical entities, use a stronger approach.

Pattern 2: Optimistic Concurrency Control (OCC) With Versioning#

Rule: the server stores a version integer. Client sends expectedVersion on update. If it does not match, server returns conflict.

ProsConsBest for
Prevents silent overwritesRequires conflict handling UXTasks, orders, profiles, invoices

This is the workhorse for offline-first sync. It forces you to deal with conflicts explicitly.

A typical server response includes both the server record and the client attempted change, enabling a merge UI.

Pattern 3: Field-Level Merge (Safe Merge)#

Rule: merge only fields that are safe, such as additive or non-overlapping fields.

Examples of safe merges:

Field typeMerge approachExample
Tags arrayUnionAdd tags without removing others
CountersAdd deltasOffline “likes” increments
Text with sectionsMerge by blocksNotes with paragraph-level edits, if supported

Field-level merge reduces conflicts dramatically when edits are independent. It does require clear rules and consistent serialization.

Pattern 4: Manual Resolution (User-Driven)#

Rule: show both versions and let the user choose or edit a merged version.

Use manual resolution when the app cannot safely merge, for example:

  • two users editing the same price field
  • two different shipping addresses
  • conflicting status transitions

🎯 Key Takeaway: If you cannot explain the merge rule in one sentence, you should not do it automatically.

Practical Conflict UX That Users Understand#

A workable UX pattern:

  1. 1
    Mark record as conflicted locally.
  2. 2
    Show a “Resolve” screen with:
    • Server version (latest)
    • Your local version
    • Highlight differing fields
  3. 3
    Provide actions:
    • Keep mine
    • Keep server
    • Edit merged result

Keep the resolution localized to the entity. Do not block the whole app.

# Background Sync in Flutter: What Works in Production#

Background sync is where many offline-first apps fail because mobile OSs are strict about background execution.

Foreground, Background, and Opportunistic Sync#

ModeTriggerReliabilityUse case
Foreground syncApp open, user action, periodic timerHighImmediate upload after edits
Background scheduledOS-managed jobsMedium on Android, lower on iOSCatch-up when user is inactive
Push-triggeredSilent push or notification tapMediumPrompt sync on important events

Design sync so that foreground sync handles most cases. Background sync is a best-effort safety net.

Backoff and Retry Policy#

Retry policy should be predictable and battery-friendly:

Failure typeExampleAction
Network unavailableairplane modePause until connectivity changes
Timeoutweak networkExponential backoff, max interval 15-30 minutes
401/403expired tokenRefresh token, then retry once
409 conflictversion mismatchMove to conflicted state, stop auto-retrying

Idempotency Keys for Safe Retries#

For any create or update, send an idempotencyKey generated per outbox item. If a request times out after the server applied it, a retry should not create duplicates.

A simple HTTP header works:

Bash
curl -X POST https://api.example.com/tasks \
  -H "Idempotency-Key: 9d1b0d7b-7c3b-4c24-9e2a-1d9f5b7f5b8a" \
  -H "Content-Type: application/json" \
  -d '{"title":"Buy milk"}'

Your server must store the key and return the same result for repeats within a retention window, commonly 24 hours.

ℹ️ Note: On iOS, assume background tasks can be killed at any time. Ensure every sync step is small, checkpointed, and can resume without corrupting local state.

# A Concrete Reference Sync Loop (Upload Then Pull)#

A reliable default is:

  1. 1
    Acquire a sync lock so only one sync runs per account.
  2. 2
    Upload pending outbox items in order, with coalescing.
  3. 3
    Pull server changes since last token.
  4. 4
    Apply changes in a transaction.
  5. 5
    Release lock and update lastSyncAt or changeToken.

Pseudocode (Flutter-Friendly)#

Dart
Future<void> runSync() async {
  if (!await syncLock.tryAcquire()) return;
 
  try {
    await uploadOutbox();      // idempotent, retries, conflict detection
    await pullChanges();       // incremental, paginated
  } finally {
    syncLock.release();
  }
}

Keep the sync loop short and safe to interrupt. Avoid long-running monolithic sync operations.

# Testing Offline-First Sync Under Flaky Networks#

If you only test on perfect Wi‑Fi, you will ship sync bugs. Your test plan should include network variability, app kills, and concurrency.

What to Test (Checklist)#

ScenarioWhat you verifyExpected result
Offline createCreate record offline, restart appRecord persists, outbox item persists
Offline updateUpdate same record 5 times offlineUI shows latest, outbox coalesced
Offline deleteDelete record offline, then recreateTombstone behavior is correct
Timeout during uploadKill app mid-requestRetry does not duplicate server data
ConflictEdit on two devicesConflict state appears, resolution works
Partial pullPull returns paginated changesToken updates correctly, no missing records
Auth expiryToken expires mid-syncRefresh then continue or pause cleanly

Simulating Network Conditions#

Use a mix of:

  • Device-level network toggles and airplane mode
  • OS network conditioning tools
  • Proxy-based throttling

On Android Emulator you can simulate latency and packet loss from extended controls. On iOS Simulator, use Network Link Conditioner on macOS to emulate profiles like 3G or high-loss networks.

Deterministic Sync Tests With a Fake Server#

A fake server that can return scripted responses will catch race conditions early.

Fake server capabilityWhy it matters
Delayed responsesTest timeouts and retries
Duplicate response replayValidate idempotency handling
Conflict injectionForce 409 responses deterministically
Out-of-order changesEnsure token and version logic is robust

Observability: Logs You Actually Need#

At minimum, log these with a correlation id per sync run:

Log fieldExample
syncRunIdUUID per run
accountIdcurrent user
outboxItemIdcurrent mutation
requestIdserver trace id if available
resultsuccess, retry, conflict, auth-failed

Without these logs, conflict bugs become “cannot reproduce” tickets.

# Cost and Scope: Plan Offline-First Early#

Offline-first is not a small checkbox feature. It affects backend APIs, client storage, QA, and UX.

If you are estimating an MVP, include offline-first explicitly in scope and budget. A typical MVP without offline-first can ship faster, but retrofitting sync later often costs significantly more because you must redesign data flows and add migrations. For budgeting guidance, see mobile app MVP cost.

# Key Takeaways#

  • Treat local storage as the source of truth, and design the UI to render from local streams, not network futures.
  • Use a durable outbox with idempotency keys so uploads are safe to retry and survive restarts.
  • Prefer incremental pull sync with change tokens and versioning to reduce bandwidth and avoid clock skew.
  • Implement explicit conflict handling: OCC with versions for critical data, safe field-level merges where possible, and manual resolution for ambiguous edits.
  • Test on flaky networks with timeouts, app kills, and deterministic conflict injection, and add sync observability logs from day one.

# Conclusion#

A solid Flutter offline-first sync setup is a combination of local-first UX, a durable outbox, incremental sync, and conflict resolution that never silently loses user data. If you want help designing the architecture, backend endpoints, or a production-grade sync engine in Flutter, Samioda can implement the full offline-first stack and testing harness end-to-end. Start by reviewing your current data model and sync requirements, then reach out for an architecture workshop and implementation plan.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.