Designing Offline-First Mobile Applications
An architectural guide to offline-first mobile engineering: transactional mutation outboxes, delta synchronization, conflict resolution, tombstones, and OS power realities.

Designing Offline-First Mobile Applications
Mobile applications operate in an inherently unstable physical environment. Smartphones travel through transit tunnels, elevators, crowded venues with congested cellular base stations, and captive Wi-Fi networks that silently drop egress traffic.
Despite these physical constraints, many applications are built under the assumption that an active, low-latency network connection is a permanent prerequisite for user interaction. In these designs, every screen navigation triggers a network request, write operations block on HTTP responses, and a momentary loss of signal renders the user interface unusable.
Building an offline-first mobile application requires reversing that mental model. Rather than treating the network as the primary data bus and local storage as a transient cache, an offline-first architecture establishes the local embedded database as the authoritative Single Source of Truth (SSOT).
The user interface reads and writes exclusively to local disk storage, while the network is relegated to an asynchronous background replication mechanism.
This guide covers the architectural principles, distributed systems patterns, conflict resolution mechanics, and mobile operating system constraints required to build production-grade offline-first mobile applications.
1. The Offline-First Paradigm: Why Caching Is Not Enough
To understand the necessity of an offline-first architecture, it is helpful to contrast it with the conventional "online-first with caching" pattern.
CONVENTIONAL ONLINE-FIRST (NETWORK AS SSOT):
User Action ──> UI Spinner ──> Network Request ──> Remote API
│
[Network Failure]
│
▼
Error Dialog / Blocked UI
TRUE OFFLINE-FIRST (LOCAL DISK AS SSOT):
User Action ──> Local SQLite Commit ──> Reactive UI Update (0ms latency)
│
▼
Persistent Outbox Queue
│
▼ (Asynchronous Background Sync)
Remote API
The Limits of Online-First with Caching
In an online-first architecture:
- The network is on the critical rendering path: The user interface initiates an HTTP request to fetch data. If the network is unavailable or experiencing high packet loss, the user faces blocking spinners or generic retry prompts.
- Caching is best-effort and transient: HTTP responses are cached using libraries like HTTP response caches or in-memory state stores. However, cache invalidation is complex, cached responses are often read-only, and cached state rarely survives deep cross-entity joins.
- Writes fail immediately: When disconnected, users cannot submit forms, edit documents, or log activity. If an optimistic UI update is attempted without persistent queuing, an application crash or process termination before request completion results in silent data loss.
The Inverted Offline-First Architecture
In a true offline-first architecture:
- Local Embedded Storage is Authoritative: SQLite (via Room on Android, Core Data or SwiftData on iOS, or Drift in Flutter) acts as the single source of truth for the presentation layer. The UI queries local storage directly using reactive streams.
- Writes are Immediate and Local: When a user creates or modifies an entity, the modification is committed to the local database within an atomic transaction. The UI updates instantly.
- The Network is an Asynchronous Sync Channel: Background workers synchronize state differences between the local database and the remote backend. Network round-trips do not block user input.
- Partition Tolerance is the Default: The application is treated as an independent node in a distributed system that must function during extended network partitions.
Architectural Comparison
| Architectural Dimension | Online-First with Caching | True Offline-First Architecture | | :--- | :--- | :--- | | Primary Source of Truth | Remote HTTP API / Cloud Database | Local Embedded Database (SQLite / Drift / Core Data) | | UI Read Latency | Variable (100ms to several seconds depending on RTT) | Predictable (sub-millisecond to low millisecond local disk read) | | Write Availability | Requires active connection; fails during partitions | Always available; commits locally and queues upstream sync | | User Experience During Degradation | Loading spinners, timeout errors, blocked screens | Uninterrupted interaction with subtle background sync indicators | | Data Flow | Bidirectional HTTP coupling directly to UI components | Unidirectional: UI reads local database; background workers sync with remote | | Conflict Handling | Handled synchronously by backend rejecting requests | Asynchronous reconciliation via revision vectors, OCC, or CRDTs | | Architectural Complexity | Lower initial setup; high maintenance as edge cases grow | Higher initial architectural investment; resilient long-term stability |
2. The Synchronization Architecture
An offline-first synchronization engine consists of two distinct pipelines: Downstream Delta Synchronization (pulling server modifications into local storage) and Upstream Mutation Synchronization (pushing locally queued user actions to the server).
┌─────────────────────────────────────────────────────────────────────────┐
│ REMOTE BACKEND │
│ ┌───────────────────────────────┐ ┌─────────────────────────────┐ │
│ │ Monotonic Changelog Feed │ │ Idempotent Mutation API │ │
│ │ (Sequence: seq_101...) │ │ (Idempotency-Key handling) │ │
│ └───────────────┬───────────────┘ └──────────────▲──────────────┘ │
└───────────────────┼──────────────────────────────────┼──────────────────┘
│ │
1. GET Changes (cursor) 4. POST Mutations (batch)
│ │
┌───────────────────┼──────────────────────────────────┼──────────────────┐
│ ▼ │ │
│ ┌───────────────────────────────┐ ┌──────────────┴──────────────┐ │
│ │ Delta Ingestion Worker │ │ Mutation Outbox Worker │ │
│ │ (Applies changes to SQLite) │ │ (Drains queue with jitter) │ │
│ └───────────────┬───────────────┘ └──────────────▲──────────────┘ │
│ │ │ │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────┴──────────────┐ │
│ │ EMBEDDED LOCAL DATABASE (SQLITE) │ │
│ │ ┌─────────────────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Domain Entity Tables │ │ Mutation Outbox Table │ │ │
│ │ └──────────────┬──────────────┘ └─────────────────────────┘ │ │
│ └──────────────────┼──────────────────────────────────────────────┘ │
│ │ Reactive Streams (Room / SwiftData / Drift) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ USER INTERFACE (VIEW) │ │
│ │ (Renders locally; zero network blocking on user interactions) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ DEVICE │
└─────────────────────────────────────────────────────────────────────────┘
Downstream Delta Synchronization (Ingress)
Naive sync implementations attempt to refresh data by downloading the entire dataset on launch. On mobile, this approach leads to excessive cellular data consumption, high battery drain, and memory spikes that trigger operating system process termination.
Downstream synchronization should always be modeled as a delta stream:
- High-Water Marks and Cursors: The local client persists a synchronization cursor—typically a 64-bit monotonically increasing sequence ID, a server change vector, or a Lamport timestamp. When synchronizing, the client sends its last known cursor:
GET /api/v1/sync/deltas?cursor=1849204&limit=250 - Paged Changesets: The server returns records modified since that cursor, accompanied by the new high-water mark cursor. Changesets should be bounded (e.g., maximum 250–500 items per response) to prevent mobile heap exhaustion and maintain smooth frame rates during JSON deserialization.
- Atomic Local Ingestion: The client processes the delta batch inside a single SQLite transaction. The local database updates entities, processes soft-deleted tombstones, and advances the local cursor within the same transaction. If the app crashes midway, the transaction rolls back, and the cursor remains at the previous valid state.
- Reactive View Emission: The UI does not await the sync worker. Instead, the UI observes local database queries via reactive abstractions—such as Kotlin Room
Flow<List<T>>, Swift Core DataNSFetchedResultsController, SwiftData@Query, or Flutter Driftwatch(). When the delta ingestion commits to SQLite, the database driver automatically notifies active observers, refreshing the screen without any direct coupling to the network layer.
Upstream Write Synchronization: The Transactional Mutation Outbox Pattern
When a user performs a write operation (e.g., editing a task, posting a comment, or updating a status), the mutation cannot rely on an in-memory queue. If the application is killed while the device is in an elevator, volatile memory is lost.
To provide durability, offline-first systems implement the Transactional Mutation Outbox Pattern:
- User Action: The user triggers a state mutation in the UI.
- Atomic Local Persistence: Within a single local SQLite transaction:
- The target entity table is updated optimistically with a status flag (
sync_status = 'pending'). - A new record is inserted into a persistent
mutation_outboxtable containing:- A client-generated UUID
idempotency_key. - The target entity name and entity ID.
- The mutation type (
CREATE,UPDATE,DELETE). - The serialized JSON payload.
- The base revision number the user modified.
- Creation timestamp and retry metadata.
- A client-generated UUID
- The target entity table is updated optimistically with a status flag (
- UI Acknowledgement: The transaction commits to the local disk. The UI receives immediate confirmation and updates its reactive state. To the user, the operation is complete.
- Outbox Draining: A background task (managed by Android
WorkManageror iOSBGProcessingTask) detects pending outbox entries. It transmits them sequentially or per entity partition to the server, including theIdempotency-Keyheader. - Server Reconciliation: The server processes the mutation idempotently. If successful, it responds with the newly assigned server revision number. The client worker updates the local entity to
sync_status = 'synced'and deletes or marks the outbox record as completed.
3. Conflict Resolution Mechanics
When multiple mobile clients make concurrent modifications while disconnected from the server, their local states diverge. When connectivity resumes, the system must reconcile these conflicting writes.
The Fallacy of Naive Last-Write-Wins (LWW)
The most common mistake in offline-first systems is relying on client-generated wall-clock timestamps (Date.now() or System.currentTimeMillis()) to resolve conflicts via Last-Write-Wins:
- Mobile NTP Clock Skew: Mobile device clocks are inherently untrusted. Device clocks drift across cellular towers, vary between hardware manufacturers, and can be manually modified by users (e.g., changing time zones or altering system clocks for mobile games).
- Network Transit Inversion: A user on an airplane may record an edit at 14:00:00 UTC. Another user on a 5G connection makes an edit to the same record at 14:00:05 UTC. If the airborne user reconnects an hour later and submits their mutation with an inflated client timestamp, naive LWW will silently overwrite the newer online change, destroying user data.
CONCURRENT EDIT SCENARIO:
Server Base State: { title: "Draft Report", assignee: "Alice", rev: 1 }
Device A (Offline): Edits title ────> { title: "Final Report", assignee: "Alice" }
Device B (Online): Edits assignee ─> { title: "Draft Report", assignee: "Bob" }
NAIVE ENTITY-LEVEL LWW:
Whichever sync request arrives last completely overwrites the entire entity.
If Device A wins: Assignee reverts to "Alice" (Bob's assignment lost).
If Device B wins: Title reverts to "Draft Report" (Device A's title lost).
FIELD-LEVEL 3-WAY MERGE:
Base: { title: "Draft Report", assignee: "Alice" }
Device A Delta: Modified [title]
Device B Delta: Modified [assignee]
Resolved State: { title: "Final Report", assignee: "Bob", rev: 2 } (Zero data loss)
Field-Level Merging vs. Entity-Level Overwriting
Instead of treating an entire record as an indivisible blob, field-level reconciliation tracks modifications at the attribute level.
In a Three-Way Merge:
- The engine compares the
base_revision(the state of the entity when the offline client began editing), thelocal_draft(the client's modified state), and theremote_head(the current server state). - If Device A altered
titlewhile Device B alteredassignee, both modifications are non-overlapping relative tobase_revision. The sync engine cleanly merges both attributes. - If both Device A and Device B modified
titleto different values, an actual collision exists. The system must then apply an explicit tie-breaking policy, execute custom business logic, or flag the collision for user resolution.
Optimistic Concurrency Control (OCC) with Revision Vectors
For entities requiring strict consistency (such as financial line items, workflow state machines, or shared project configurations), mobile systems should implement Optimistic Concurrency Control (OCC):
- Every entity includes an integer
revisioncounter or an opaque stringetaggenerated by the backend. - When the mobile client submits an update, it sends the base revision it branched from using standard HTTP conditional semantics:
PUT /api/v1/projects/proj_881 If-Match: "rev_4" Content-Type: application/json Idempotency-Key: b78d8c60-8f92-4f32-84b2-299e564d26f1 { "name": "Q4 Infrastructure Redesign", "budget": 45000 } - If another client has updated the project to
rev_5in the interim, the server rejects the incoming mutation with HTTP 409 Conflict, returning the currentrev_5payload in the response body. - The mobile sync engine receives the 409 response, fetches the remote diff, runs the local domain reconciliation algorithm, and either transparently retries with an incremented base revision or prompts the user.
Conflict-Free Replicated Data Types (CRDTs)
For rich collaborative features—such as multi-user document authoring, real-time shared whiteboards, or decentralized checklist management—state-based or operation-based Conflict-Free Replicated Data Types (CRDTs) provide mathematical convergence without centralized locking.
- State-Based CRDTs (CvRDTs): Replicas exchange their full internal state and merge them using a mathematically proven join-semilattice (a join operation that is commutative, associative, and idempotent). Examples include PN-Counters (Positive-Negative Counters) and LWW-Element-Sets.
- Operation-Based CRDTs (CmRDTs): Replicas transmit immutable operations over a causally ordered messaging transport.
- Production Trade-Offs: While CRDTs prevent write conflicts, they carry significant overhead. CRDT metadata (vector clocks, actor IDs, per-character identifiers) can cause local storage and network payloads to grow several times larger than the raw domain data. Furthermore, CRDT tombstones (markers for deleted items) must be preserved indefinitely unless an active garbage-collection protocol is maintained across all nodes.
Conflict Resolution Strategy Matrix
| Strategy | Ideal Use Cases | Engineering Complexity | Trade-Offs & Failure Modes | | :--- | :--- | :--- | :--- | | Optimistic Concurrency Control (OCC) | Business records, status transitions, inventory, financial ledgers | Moderate | Rejects concurrent writes with HTTP 409; requires client-side recovery or retry logic. | | Field-Level 3-Way Merge | Multi-field user profile data, forms, independent configuration properties | Moderate | Requires persisting the base revision snapshot; overlapping field collisions require fallback logic. | | CRDTs (LWW-Element-Set, RGA) | Collaborative documents, note-taking, shared checklists, active multi-user canvases | High | High metadata storage overhead; requires complex tombstone compaction across devices. | | Server-Side Domain Rules | Monotonic counters, appending audit logs, set unions | Low to Moderate | Restricted to operations that can be modeled as commutative server-side commands. | | Manual User Resolution | Mutually exclusive business decisions, conflicting document overwrites | High (UI/UX) | Interrupts user workflow; requires specialized comparison dialogs and state management. |
4. Deletion Semantics & The Tombstone Pattern
Handling deletions in an offline-first distributed architecture requires special care. A common failure in mobile implementations is the Resurrection Bug.
THE RESURRECTION BUG:
1. Device goes offline.
2. User taps "Delete Task".
3. Naive Code runs: DELETE FROM tasks WHERE id = 'task_123'; (Row deleted from local disk)
4. Device reconnects and runs Delta Sync: GET /sync/deltas?cursor=seq_50
5. Server sends changes since seq_50.
6. Server response includes 'task_123' (created or updated earlier by another client).
7. Client inspects local SQLite: 'task_123' does not exist.
8. Client inserts 'task_123' into local SQLite.
RESULT: The deleted task reappears on the user's screen!
The Tombstone Pattern
To prevent resurrection, deletions must be represented as explicit state transitions rather than immediate physical row purges:
- Soft Delete Schema: Entity tables must include deletion metadata:
ALTER TABLE tasks ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0; ALTER TABLE tasks ADD COLUMN deleted_at INTEGER; - Local Deletion Execution: When a user deletes an entity:
- Within an atomic transaction, the local record is updated:
is_deleted = 1anddeleted_at = :currentTime. - A
DELETEmutation entry is inserted intomutation_outbox.
- Within an atomic transaction, the local record is updated:
- Query Filtering: All active UI queries filter out tombstones:
SELECT * FROM tasks WHERE is_deleted = 0 ORDER BY priority DESC; - Replication & Purging:
- The outbox worker pushes the deletion to the server:
DELETE /api/v1/tasks/task_123. - The server marks the record as deleted in its central database, records a deletion event in its monotonic changelog, and acknowledges the request.
- The local tombstone remains on the mobile device until the client's downstream sync cursor has advanced past the server-acknowledged deletion event. Once confirmed that no upstream delta will reference the deleted entity, a background cleanup job can physically execute
DELETE FROM tasks WHERE id = ?.
- The outbox worker pushes the deletion to the server:
5. Mobile Platform Realities & Power Management
Desktop and server distributed systems run in controlled environments with stable power and predictable execution lifecycles. Mobile devices, by contrast, are governed by strict operating system battery optimizations and memory pressure killers.
CELLULAR RADIO RESOURCE CONTROL (RRC) POWER STATES:
┌──────────────┐ Data Transmission Triggered ┌───────────────────┐
│ RRC_IDLE │ ───────────────────────────────────────────> │ RRC_CONNECTED │
│ (~10-15 mW) │ │ (~1000-2500 mW) │
└──────────────┘ └─────────┬─────────┘
▲ │
│ │ Inactivity Timer
│ Inactivity Timer ▼ Expired
│ ┌──────────────────────────────────────────────────┐
└────────────────────── │ Tail State (C-DRX / Intermediate) │
│ High power draw maintained for seconds │
└──────────────────────────────────────────────────┘
1. Cellular Radio Power States (RRC) and Tail Energy
Cellular modems (4G LTE and 5G NR) do not transmit data continuously. They are governed by telecommunication base stations via the Radio Resource Control (RRC) state machine:
- RRC_IDLE: Low-power standby state (~10–15 mW). The modem listens only to paging channels.
- RRC_CONNECTED: High-power transmission state (~1,000–2,500 mW). High-frequency power amplifiers and baseband processors are fully energized.
- The Tail Energy Trap: After an application finishes transmitting packets, the cellular base station keeps the modem in an energized "tail state" for several seconds (often 5 to 15 seconds depending on carrier configuration) before allowing it to return to
RRC_IDLE. This tail state avoids the latency of re-negotiating radio channels for consecutive packets.
The Architectural Consequence: If an outbox worker transmits mutations individually as single HTTP requests spaced 10 seconds apart, the cellular radio never drops to RRC_IDLE. The device remains trapped in peak power draw, causing noticeable battery drain.
Mitigation:
- Coalesce and Batch: Outbox mutations should be drained in batches within a single network connection.
- Piggybacking: When the application wakes to perform an unavoidable foreground request, the outbox worker should seize that opportunity to flush pending mutations while the radio is already in
RRC_CONNECTED.
2. Captive Portal Detection vs. Link Connectivity
Mobile operating systems provide connectivity callbacks (ConnectivityManager on Android, NWPathMonitor on iOS). However, these callbacks only verify that a network link (e.g., Wi-Fi association) exists; they do not guarantee public internet egress.
In hotels, airports, and corporate networks, devices frequently connect to captive portals that intercept all HTTP requests and return HTTP 200 or 302 responses pointing to login or payment forms.
If an outbox sync worker blindly flushes pending mutations upon detecting a Wi-Fi connection, the API client may submit JSON payloads to a captive portal login endpoint. If the client fails to validate the response structure or SSL certificate, it may misinterpret the captive portal response, leading to dropped mutations or corrupted sync state.
Mitigation:
- Prior to initiating an outbox flush, execute a lightweight egress check (such as requesting a known endpoint that returns HTTP 204 No Content, e.g., Android's
generate_204or an application health probe). - Ensure strict HTTPS certificate pinning or public key verification to detect captive interception before transmitting sensitive outbox payloads.
3. OS Process Termination (iOS Jetsam & Android Low-Memory Killer)
Mobile operating systems enforce aggressive process lifecycle rules. If a user switches to a memory-intensive application (like the camera or a navigation app), the OS will terminate background processes without warning.
On iOS, the Jetsam kernel subsystem monitors system-wide memory thresholds and kills high-memory processes using SIGKILL (which cannot be caught or handled in application code). On Android, the Low Memory Killer daemon (lmkd) inspects process oom_score_adj values and kills background processes when free pages drop below configured levels.
The Durability Invariant:
- A mutation must be written to disk before confirming the action in the UI.
- Local SQLite databases must use Write-Ahead Logging (WAL) mode (
PRAGMA journal_mode = WAL;) and appropriate synchronous disk writes (PRAGMA synchronous = NORMAL;orFULL). - If an in-flight background sync worker is terminated by the OS mid-flight, the transactional outbox ensures that no mutations are lost. When the app is next launched or scheduled by
WorkManager/BGProcessingTask, the outbox will resume from the last unacknowledged entry.
6. Implementation Blueprint: Outbox & OCC Sync Engine
The following complete, type-safe TypeScript implementation illustrates a production-grade Transactional Mutation Outbox Engine equipped with exponential backoff, full jitter, mutex locks to prevent overlapping sync loops, and 3-way Optimistic Concurrency Control (OCC) conflict resolution.
/**
* Core types for the Offline-First Mutation Outbox Engine.
*/
export type SyncStatus = 'PENDING' | 'IN_FLIGHT' | 'SYNCED' | 'FAILED';
export type MutationType = 'CREATE' | 'UPDATE' | 'DELETE';
export interface MutationOutboxEntry<T> {
readonly id: string;
readonly idempotencyKey: string;
readonly entityType: string;
readonly entityId: string;
readonly mutationType: MutationType;
readonly payload: T;
readonly baseRevision: number;
readonly createdAt: number;
readonly attemptCount: number;
readonly lastAttemptAt: number | null;
readonly nextRetryAt: number;
readonly status: SyncStatus;
readonly errorMessage: string | null;
}
export interface EntityRecord<T> {
readonly id: string;
readonly revision: number;
readonly syncStatus: SyncStatus;
readonly isDeleted: boolean;
readonly deletedAt: number | null;
readonly data: T;
}
export interface MergeResult<T> {
readonly merged: T;
readonly hasConflict: boolean;
readonly conflictingKeys: string[];
}
/**
* Three-Way Field Merge Implementation.
* Compares the base snapshot, the local pending draft, and the remote server state.
*/
export function threeWayFieldMerge<T extends Record<string, unknown>>(
base: T,
local: T,
remote: T
): MergeResult<T> {
const merged = { ...base, ...remote } as Record<string, unknown>;
const conflictingKeys: string[] = [];
const allKeys = new Set([
...Object.keys(base),
...Object.keys(local),
...Object.keys(remote),
]);
for (const key of allKeys) {
const baseVal = base[key];
const localVal = local[key];
const remoteVal = remote[key];
const localChanged = JSON.stringify(baseVal) !== JSON.stringify(localVal);
const remoteChanged = JSON.stringify(baseVal) !== JSON.stringify(remoteVal);
if (localChanged && !remoteChanged) {
// Clean local modification: preserve local edit
merged[key] = localVal;
} else if (!localChanged && remoteChanged) {
// Clean remote modification: accept remote edit
merged[key] = remoteVal;
} else if (localChanged && remoteChanged) {
if (JSON.stringify(localVal) === JSON.stringify(remoteVal)) {
// Identical concurrent edits: cleanly converge
merged[key] = localVal;
} else {
// Unresolvable concurrent edit on the same field
conflictingKeys.push(key);
merged[key] = remoteVal; // Default conservative fall-back
}
}
}
return {
merged: merged as T,
hasConflict: conflictingKeys.length > 0,
conflictingKeys,
};
}
/**
* Calculates exponential backoff with full jitter to avoid thundering herds.
* Formula: random_between(0, min(maxDelay, baseDelay * 2^attempt))
*/
export function calculateBackoffWithJitter(
attempt: number,
baseDelayMs = 1000,
maxDelayMs = 30000
): number {
const exponential = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * exponential);
}
/**
* Database and Network Abstraction Interfaces.
*/
export interface StorageAdapter<T extends Record<string, unknown>> {
getPendingOutboxEntries(limit: number): Promise<MutationOutboxEntry<T>[]>;
markOutboxInFlight(id: string): Promise<void>;
markOutboxSuccess(id: string, newRevision: number): Promise<void>;
markOutboxFailed(id: string, errorMessage: string, nextRetryAt: number): Promise<void>;
getEntity(entityId: string): Promise<EntityRecord<T> | null>;
updateEntity(entityId: string, record: EntityRecord<T>): Promise<void>;
runInTransaction<R>(operation: () => Promise<R>): Promise<R>;
}
export interface RemoteApiResponse<T> {
readonly success: boolean;
readonly status: number;
readonly revision?: number;
readonly currentServerState?: T;
readonly error?: string;
}
export interface RemoteApi<T> {
executeMutation(
entry: MutationOutboxEntry<T>,
idempotencyKey: string
): Promise<RemoteApiResponse<T>>;
}
/**
* The Production-Grade Outbox Synchronization Engine.
*/
export class OutboxSyncEngine<T extends Record<string, unknown>> {
private isProcessing = false;
constructor(
private readonly storage: StorageAdapter<T>,
private readonly api: RemoteApi<T>,
private readonly maxRetries = 5,
private readonly baseDelayMs = 1000,
private readonly maxDelayMs = 30000
) {}
/**
* Drains pending outbox mutations. Protected by a mutex to prevent
* concurrent worker executions from racing on identical entries.
*/
public async drainOutbox(batchSize = 20): Promise<{ processed: number; failed: number }> {
if (this.isProcessing) {
return { processed: 0, failed: 0 };
}
this.isProcessing = true;
let processedCount = 0;
let failedCount = 0;
try {
const entries = await this.storage.getPendingOutboxEntries(batchSize);
for (const entry of entries) {
if (entry.nextRetryAt > Date.now()) {
continue; // Backoff window currently active
}
const success = await this.processEntry(entry);
if (success) {
processedCount++;
} else {
failedCount++;
}
}
} finally {
this.isProcessing = false;
}
return { processed: processedCount, failed: failedCount };
}
private async processEntry(entry: MutationOutboxEntry<T>): Promise<boolean> {
await this.storage.markOutboxInFlight(entry.id);
try {
const response = await this.api.executeMutation(entry, entry.idempotencyKey);
if (response.success && response.revision !== undefined) {
// Success: atomically update entity revision and mark outbox entry completed
await this.storage.runInTransaction(async () => {
await this.storage.markOutboxSuccess(entry.id, response.revision!);
const local = await this.storage.getEntity(entry.entityId);
if (local) {
await this.storage.updateEntity(entry.entityId, {
...local,
revision: response.revision!,
syncStatus: 'SYNCED',
});
}
});
return true;
}
if (response.status === 409 && response.currentServerState) {
// OCC 409 Conflict: Reconcile diverged server state
return await this.handleConflict(
entry,
response.currentServerState,
response.revision ?? entry.baseRevision + 1
);
}
// Transient failure (5xx server error, timeout)
await this.handleRetry(entry, response.error ?? `HTTP ${response.status}`);
return false;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
await this.handleRetry(entry, message);
return false;
}
}
private async handleConflict(
entry: MutationOutboxEntry<T>,
remoteState: T,
remoteRevision: number
): Promise<boolean> {
const localEntity = await this.storage.getEntity(entry.entityId);
if (!localEntity) {
await this.storage.markOutboxFailed(
entry.id,
'Entity not found locally during conflict resolution',
Infinity
);
return false;
}
const mergeResult = threeWayFieldMerge(
entry.payload,
localEntity.data,
remoteState
);
if (!mergeResult.hasConflict) {
// Clean non-overlapping merge: persist merged state and complete outbox entry
await this.storage.runInTransaction(async () => {
await this.storage.markOutboxSuccess(entry.id, remoteRevision);
await this.storage.updateEntity(entry.entityId, {
...localEntity,
revision: remoteRevision,
syncStatus: 'SYNCED',
data: mergeResult.merged,
});
});
return true;
}
// Unresolvable field-level collision: flag entity for domain intervention
await this.storage.runInTransaction(async () => {
await this.storage.markOutboxFailed(
entry.id,
`Field collision on keys: ${mergeResult.conflictingKeys.join(', ')}`,
Infinity // Stop automatic retries until resolved
);
await this.storage.updateEntity(entry.entityId, {
...localEntity,
syncStatus: 'FAILED',
});
});
return false;
}
private async handleRetry(entry: MutationOutboxEntry<T>, errorMessage: string): Promise<void> {
const nextAttempt = entry.attemptCount + 1;
if (nextAttempt >= this.maxRetries) {
// Max retry budget exhausted: halt automatic retry
await this.storage.markOutboxFailed(
entry.id,
`Exhausted ${this.maxRetries} retries: ${errorMessage}`,
Infinity
);
} else {
const delay = calculateBackoffWithJitter(nextAttempt, this.baseDelayMs, this.maxDelayMs);
await this.storage.markOutboxFailed(entry.id, errorMessage, Date.now() + delay);
}
}
}
7. Storage Engine Selection for Offline-First Mobile
Selecting an appropriate mobile embedded storage engine directly determines query latency, migration ergonomics, and reactive UI capabilities.
Embedded Storage Comparison
| Storage Engine | Primary Platforms | Storage Model | Reactive UI Query Support | Encryption Support | Concurrency Model |
| :--- | :--- | :--- | :--- | :--- | :--- |
| SQLite (Raw / Room / GRDB) | Native Android, Native iOS | Relational (SQL) | Native (Flow, Publisher, AsyncSequence) | SQLCipher (256-bit AES) | Multi-reader single-writer (WAL mode) |
| Drift | Flutter (Multiplatform) | Relational (Dart / SQL) | Native Dart Streams (watch()) | Supported via SQLCipher integration | WebWorker / Isolate background thread execution |
| WatermelonDB | React Native, Web | Relational (SQLite / LokiJS) | Observable RxJS streams | Supported via custom SQLite drivers | Lazy loading with separate worker thread |
| Realm / Atlas Device SDK | Android, iOS, React Native | Object-Oriented Database | Live Objects and Notification Tokens | Built-in 256-bit AES encryption | Multi-version Concurrency Control (MVCC) |
Key Storage Evaluation Criteria
- Reactive Observation: The storage engine must support reactive subscriptions (e.g., SQLite table change notifications hooked into Kotlin
Flowor SwiftPublisher). If the UI must manually poll or re-query whenever the outbox or delta worker updates a table, the decoupling between sync and presentation is compromised. - Write-Ahead Logging (WAL): In traditional SQLite rollback journal mode, writing a batch of incoming delta rows locks the entire database, preventing the UI thread from executing reads and causing frame drops. Enabling
PRAGMA journal_mode = WAL;allows concurrent readers to query the database without blocking background writes. - Migration Ergonomics: Offline clients cannot be forced to update their local database schema synchronously like a cloud backend. A device might skip five application versions before reconnecting. Schema migrations must be strictly versioned, tested with automated migration test runners, and backward-compatible.
8. Practical Production Verification Checklist
Before releasing an offline-first mobile application, verify the architecture against these production scenarios:
Local Persistence & Storage Architecture
- [ ] Reactive UI Decoupling: Screens query the local database via reactive streams; no UI component directly invokes HTTP endpoints for view rendering.
- [ ] Write-Ahead Logging: SQLite operates in
WALmode (PRAGMA journal_mode = WAL;) with appropriate synchronous settings (NORMALorFULL). - [ ] Automated Migration Testing: Multi-version database schema migrations are validated using integration tests verifying upgrade paths across non-consecutive versions.
- [ ] Tombstone Filtering: All active domain queries explicitly filter out soft-deleted records (
WHERE is_deleted = 0).
Mutation Outbox & Upstream Synchronization
- [ ] Atomic Enqueueing: Local entity mutations and outbox entries are committed within the same atomic SQLite transaction.
- [ ] Idempotency Keys: Every mutation entry carries a client-generated UUID
Idempotency-Keysent via HTTP headers to prevent duplicate execution on server retries. - [ ] Exponential Backoff with Full Jitter: Failed outbox submissions retry using exponential backoff with randomized jitter to prevent server thundering herds.
- [ ] Mutex Concurrency Locks: Outbox workers use synchronization locks to prevent multiple worker invocations from processing identical queue partitions concurrently.
Delta Sync & Downstream Ingestion
- [ ] Bounded Changeset Paging: Delta sync requests enforce strict batch limits (e.g., 250 records) to avoid memory pressure and garbage-collection frame drops.
- [ ] Atomic Cursor Advancement: High-water mark sequence tokens advance only within the same transaction that commits the received delta batch.
- [ ] Schema Backward-Compatibility: Downstream JSON parsers ignore unknown fields to support additive backend schema changes gracefully.
Conflict Resolution & Invariants
- [ ] Absence of Naive LWW: No conflict logic relies on uncalibrated client wall-clock timestamps (
Date.now()). - [ ] OCC 409 Handling: Server revision mismatches trigger automated field-level 3-way merges or explicit domain conflict recovery.
- [ ] Collision Escalation: Conflicting modifications on identical fields halt automated retries and notify the user or domain handler without silent data truncation.
Platform Lifecycle & Power Management
- [ ] Captive Portal Protection: Outbox sync performs egress verification before draining queues on newly connected Wi-Fi links.
- [ ] Radio Energy Batching: Mutations are coalesced into batch network requests to minimize cellular RRC tail state energy drain.
- [ ] Process Termination Safety: In-flight mutations survive abrupt OS process termination (
Jetsam/lmkd) without data corruption. - [ ] Persistent Job Scheduling: Background synchronization delegates to persistent platform schedulers (
WorkManageron Android,BGProcessingTaskon iOS) that survive device reboots.
Conclusion
Designing an offline-first mobile application is fundamentally an exercise in distributed systems engineering under mobile hardware constraints.
By establishing the local embedded database as the authoritative Single Source of Truth, applications achieve instant, predictable UI latency and complete resilience during network partitions. Combining downstream delta streams with transactional mutation outboxes decouples the user experience from the volatile realities of cellular connectivity.
When coupled with rigorous conflict resolution mechanics, proactive tombstone management, and respect for cellular radio power profiles, an offline-first architecture transforms mobile software from a fragile network client into a robust, autonomous computing platform.


