Build mobile applications that work reliably without network connectivity. Covers offline data storage, sync strategies, conflict resolution, queue-based operations, optimistic UI, and the patterns that make offline-first feel seamless rather than broken.
Mobile Offline-First Architecture is a design approach where mobile apps assume network connectivity is unreliable, intermittent, or absent, and treat the local device as the primary source of truth. It matters when users operate in low-bandwidth environments—on trains, in rural areas, or with poor cellular coverage—and when app functionality must remain available even when the device is disconnected from the network.
Offline-first apps must manage local state and reconcile it with remote state. The core unit of work is a data model, typically represented as a set of structured entities: User, Post, Comment, Task, Event. Each entity is stored in a local database with a consistent schema, using a real, persistent storage engine.
The most common choice is SQLite, used directly via a library like Realm, WatermelonDB, or Expo’s SQLite API. A typical Post table has the following schema:
CREATE TABLE posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME,
is_synced BOOLEAN DEFAULT 0,
is_deleted BOOLEAN DEFAULT 0,
remote_version INTEGER
);
The is_synced flag indicates whether the local change has been successfully uploaded to the backend. remote_version tracks conflict resolution and is used in optimistic updates.
Sync strategy begins with a sync engine, which periodically or event-drivenly synchronizes local changes with the remote API. The engine runs in a background task, triggered by:
ConnectivityManager on Android, Reachability on iOS)Sync is initiated via a sync() function that follows this algorithm:
WHERE is_synced = 0)patch payload: {
"id": "post-123",
"title": "Offline-first is essential",
"body": "When network fails, the app must still work.",
"updated_at": "2024-08-05T12:34:56Z",
"remote_version": 5
}
PATCH /api/posts/{id} for each changed record.is_synced = 1 and update remote_version.When multiple users edit the same record offline, conflicts arise. The default algorithm is last-write-wins, but better results come from merge strategies.
The most robust pattern uses versioned records and merge trees. When a record is updated locally, its remote_version is incremented. On sync, the remote server checks if the remote_version matches the current version. If not, a conflict is detected.
Example conflict response:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "conflict",
"conflict": {
"local": {
"id": "post-123",
"title": "Offline-first is essential",
"updated_at": "2024-08-05T12:34:56Z",
"remote_version": 5
},
"remote": {
"id": "post-123",
"title": "Offline-first is essential",
"body": "A solid foundation for mobile apps.",
"updated_at": "2024-08-05T12:35:10Z",
"remote_version": 7
}
}
}
The app resolves this by merging both versions. A common strategy is last-write-wins with field-level merge:
title is updated on remote, so it wins.body is updated locally, so it wins.updated_at is set to the remote timestamp.conflict_id is stored locally to track the merge.The merged record becomes:
{
"id": "post-123",
"title": "Offline-first is essential",
"body": "When network fails, the app must still work.",
"updated_at": "2024-08-05T12:35:10Z",
"remote_version": 8,
"is_synced": true,
"conflict_id": "conflict-123"
}
This merge is recorded in a conflicts table:
CREATE TABLE conflicts (
id TEXT PRIMARY KEY,
local_record_id TEXT,
remote_record_id TEXT,
merged_record_id TEXT,
resolution_strategy TEXT,
resolved_at DATETIME,
resolved_by TEXT
);
The UI must reflect that the app is offline, and provide feedback for pending actions.
When a user creates a new Post while offline, the app:
is_synced = 0.For read-only views, the app renders from local data immediately. On network availability, it fetches fresh data in the background and updates the UI with a pull-to-refresh pattern.
State is managed via a local state store that persists across app restarts. The app uses a single source of truth: a state.json file stored in the app’s Documents directory (iOS) or SharedPreferences (Android), or in a global object in memory.
Example state structure:
{
"activeRoute": "/posts",
"currentUserId": "user-456",
"posts": {
"ids": ["post-123", "post-456"],
"entities": {
"post-123": {
"id": "post-123",
"title": "Offline-first is essential",
"body": "When network fails, the app must still work.",
"created_at": "2024-08-05T12:34:56Z",
"is_synced": false,
"is_deleted": false
}
}
},
"sync": {
"lastRun": "2024-08-05T12:34:56Z",
"pendingChanges": 3,
"isRunning": true
}
}
The app updates this file on every significant state change and reads it on startup. On failure to write, it logs the error and schedules a retry.
Sync runs in the background and must survive app restarts and device sleep.
On Android, use WorkManager to schedule periodic sync tasks:
val syncWork = OneTimeWorkRequestBuilder<SyncWorker>()
.setInitialDelay(15, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueue(syncWork)
The SyncWorker class extends CoroutineWorker and runs the sync logic in a coroutine:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val syncResult = sync()
return if (syncResult.success) {
Result.success()
} else {
Result.retry()
}
}
}
On iOS, use BackgroundTask and NSURLSession with BackgroundSessionConfiguration.
The sync engine logs its progress to a sync.log file in the app’s file system:
2024-08-05T12:34:56Z Sync started
2024-08-05T12:34:58Z Found 3 unsynced records
2024-08-05T12:35:01Z POST /api/posts/123: 201 Created
2024-08-05T12:35:03Z PATCH /api/posts/456: 200 OK
2024-08-05T12:35:04Z Conflict detected for post-789: remote_version=5, local_version=4
2024-08-05T12:35:05Z Sync completed successfully
If the device is locked or the app is killed, the sync continues in the background and resumes on next launch.
Offline-first apps fail silently, and users expect recovery.
Common failure modes:
PRAGMA integrity_check;.When a sync attempt fails, the app stores the failed request payload and error response in a failed_syncs table:
CREATE TABLE failed_syncs (
id TEXT PRIMARY KEY,
request_method TEXT,
request_url TEXT,
request_body TEXT,
response_status INTEGER,
response_body TEXT,
error_timestamp DATETIME,
retries INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3
);
The sync engine runs a retry loop:
failed_syncs WHERE retries < max_retries.retries = retries + 1.retries and schedule next attempt.max_retries, move to error state and alert the user.A critical failure is when the database becomes unavailable. The app checks for SQLite3 errors:
SQLITE_BUSY: another process is accessing the database.SQLITE_LOCKED: a transaction is in progress.SQLITE_IOERR: disk I/O error.On IOERR, the app logs the error and disables writes until the database is stable.
Offline-first apps must be efficient in data transfer and memory usage.
The API should support delta updates and batched operations.
When syncing, the client sends a PATCH /api/sync request with a batch of changes:
PATCH /api/sync
Content-Type: application/json
{
"changes": [
{
"table": "posts",
"action": "update",
"id": "post-123",
"data": {
"title": "Offline-first is essential",
"updated_at": "2024-08-05T12:34:56Z"
},
"remote_version": 5
},
{
"table": "posts",
"action": "create",
"id": "post-456",
"data": {
"title": "Building better mobile apps",
"body": "Use offline-first patterns.",
"created_at": "2024-08-05T12:35:00Z"
}
}
],
"client_timestamp": "2024-08-05T12:35:05Z"
}
The server returns a 207 Multi-Status response with individual status codes for each change:
HTTP/1.1 207 Multi-Status
Content-Type: application/json
[
{
"id": "post-123",
"status": 200,
"message": "Updated successfully",
"remote_version": 6,
"updated_at": "2024-08-05T12:35:10Z"
},
{
"id": "post-456",
"status": 201,
"message": "Created successfully",
"remote_version": 1,
"updated_at": "2024-08-05T12:35:11Z"
}
]
The app uses ETags and If-Match headers for conditional updates:
PATCH /api/posts/123
If-Match: "v5-abc123"
If the server’s ETag doesn’t match, it returns 412 Precondition Failed, and the client retries with the new version.
The app must react dynamically to network availability.
When the network becomes available (via ConnectivityManager or Reachability), the app:
If the network drops during a sync, the app:
On app launch, the app checks network status and:
For users with poor connectivity, the app downloads previews of upcoming content:
The app uses Cache-Control headers to manage data freshness:
Cache-Control: max-age=300, stale-while-revalidate=3600
This allows the app to serve cached data while simultaneously refreshing it in the background.
This architecture ensures that mobile apps are not just *available* offline, but *productive* and *reliable* in any network condition.
This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy