# 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.
Comparison: Popular Local Storage Choices#
| Option | Best for | Pros | Cons | Typical offline-first use |
|---|---|---|---|---|
| Drift (SQLite) | Relational data, complex queries | Mature SQL, joins, migrations, strong tooling | More boilerplate, need schema design | Tasks, inventory, CRM-like apps |
| Isar | Fast object storage and queries | High performance, simple object model | Less natural for complex relations, schema changes need planning | Content apps, catalogs, local-first feeds |
| Hive | Simple key-value and small datasets | Easy setup, lightweight | Not ideal for complex queries, fewer constraints | Settings, small caches, feature flags |
| SharedPreferences | Tiny config | Built-in, trivial | Not a database, no querying | Onboarding flags, selected account |
| Sembast | Document store | Flexible JSON, no native deps | Performance varies, fewer advanced query features | Prototypes, 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:
| Field | Type | Purpose |
|---|---|---|
id | string | Stable identifier (prefer UUID v4) |
updatedAt | datetime | Last local update time for UX and merges |
serverUpdatedAt | datetime nullable | Last known server update time |
version | int nullable | Optimistic concurrency token from server |
syncStatus | enum | Clean, pending, syncing, failed, conflicted |
deleted | bool | Tombstone 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#
| Component | Responsibility | Notes |
|---|---|---|
| Local DB (Drift or Isar) | Store entities, outbox, sync metadata | Transactional updates matter |
| Repository | Expose streams and write methods | Always write locally first |
| Outbox | Queue of pending mutations | Durable, ordered, idempotent |
| Sync Engine | Upload outbox, then pull server changes | Runs foreground and background |
| Conflict Resolver | Decide merge strategy | Automatic where safe, manual where needed |
| API Client | Network calls with retries | Must support idempotency keys |
Data Flow: Write Path#
- 1User edits a record.
- 2Repository writes changes to local DB in a transaction.
- 3Repository enqueues an outbox item representing the mutation.
- 4UI updates immediately from local stream.
- 5Sync 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#
| Column | Example | Why it matters |
|---|---|---|
id | uuid | Unique outbox item id |
entityType | task | Helps routing |
entityId | task_123 | Target entity |
operation | create / update / delete | Mutation type |
payload | JSON string | Delta or full object |
createdAt | timestamp | Ordering and debugging |
attemptCount | 0..n | Backoff decisions |
status | pending/sending/failed | Recoverability |
idempotencyKey | uuid | Prevent duplicate server writes |
Drift Example: Outbox Table and Enqueue#
// 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:
| Strategy | How it works | When to use |
|---|---|---|
| Coalesce by entity | Keep only the latest update per entity | High-frequency edits like notes, forms |
| Preserve full history | Upload all mutations in order | Workflows 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.
Recommended Pull API Shape#
Prefer these endpoints server-side:
| Endpoint | Returns | Key fields |
|---|---|---|
GET /changes?since=token | List of changed records plus a new token | entityType, entityId, operation, data, version, serverUpdatedAt |
GET /snapshot?cursor=x | Paginated full dataset | For 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.
| Pros | Cons | Best for |
|---|---|---|
| Simple, fast | Silent data loss possible | Non-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.
| Pros | Cons | Best for |
|---|---|---|
| Prevents silent overwrites | Requires conflict handling UX | Tasks, 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 type | Merge approach | Example |
|---|---|---|
| Tags array | Union | Add tags without removing others |
| Counters | Add deltas | Offline “likes” increments |
| Text with sections | Merge by blocks | Notes 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:
- 1Mark record as
conflictedlocally. - 2Show a “Resolve” screen with:
- Server version (latest)
- Your local version
- Highlight differing fields
- 3Provide 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#
| Mode | Trigger | Reliability | Use case |
|---|---|---|---|
| Foreground sync | App open, user action, periodic timer | High | Immediate upload after edits |
| Background scheduled | OS-managed jobs | Medium on Android, lower on iOS | Catch-up when user is inactive |
| Push-triggered | Silent push or notification tap | Medium | Prompt 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 type | Example | Action |
|---|---|---|
| Network unavailable | airplane mode | Pause until connectivity changes |
| Timeout | weak network | Exponential backoff, max interval 15-30 minutes |
| 401/403 | expired token | Refresh token, then retry once |
| 409 conflict | version mismatch | Move 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:
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:
- 1Acquire a sync lock so only one sync runs per account.
- 2Upload pending outbox items in order, with coalescing.
- 3Pull server changes since last token.
- 4Apply changes in a transaction.
- 5Release lock and update
lastSyncAtorchangeToken.
Pseudocode (Flutter-Friendly)#
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)#
| Scenario | What you verify | Expected result |
|---|---|---|
| Offline create | Create record offline, restart app | Record persists, outbox item persists |
| Offline update | Update same record 5 times offline | UI shows latest, outbox coalesced |
| Offline delete | Delete record offline, then recreate | Tombstone behavior is correct |
| Timeout during upload | Kill app mid-request | Retry does not duplicate server data |
| Conflict | Edit on two devices | Conflict state appears, resolution works |
| Partial pull | Pull returns paginated changes | Token updates correctly, no missing records |
| Auth expiry | Token expires mid-sync | Refresh 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 capability | Why it matters |
|---|---|
| Delayed responses | Test timeouts and retries |
| Duplicate response replay | Validate idempotency handling |
| Conflict injection | Force 409 responses deterministically |
| Out-of-order changes | Ensure token and version logic is robust |
Observability: Logs You Actually Need#
At minimum, log these with a correlation id per sync run:
| Log field | Example |
|---|---|
syncRunId | UUID per run |
accountId | current user |
outboxItemId | current mutation |
requestId | server trace id if available |
result | success, 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
More in Mobile Development
All →Flutter Performance Optimization: How We Keep Apps at 60fps (Profiling + Fixes)
A repeatable Flutter performance optimization workflow using DevTools to diagnose jank, then apply targeted fixes: rebuild reduction, rendering improvements, image pipelines, and isolates — with budgets and checklists.
Flutter App Architecture That Scales: Clean Architecture vs Feature-First (With Real Folder Structures)
A practical guide to Flutter app architecture in 2026: compare Clean Architecture and Feature-First, see real folder structures, dependency boundaries, and testing strategies, and choose the right approach for your team and release cadence.
Flutter + Firebase: Complete Tutorial for 2026 (Auth, Firestore, Functions, Deploy)
A step-by-step flutter firebase tutorial for 2026: set up Firebase, add authentication, build Firestore CRUD, write Cloud Functions, and deploy a production-ready app.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter Performance Optimization: How We Keep Apps at 60fps (Profiling + Fixes)
A repeatable Flutter performance optimization workflow using DevTools to diagnose jank, then apply targeted fixes: rebuild reduction, rendering improvements, image pipelines, and isolates — with budgets and checklists.
Flutter App Architecture That Scales: Clean Architecture vs Feature-First (With Real Folder Structures)
A practical guide to Flutter app architecture in 2026: compare Clean Architecture and Feature-First, see real folder structures, dependency boundaries, and testing strategies, and choose the right approach for your team and release cadence.
Flutter + Firebase: Complete Tutorial for 2026 (Auth, Firestore, Functions, Deploy)
A step-by-step flutter firebase tutorial for 2026: set up Firebase, add authentication, build Firestore CRUD, write Cloud Functions, and deploy a production-ready app.