Build mobile apps that work reliably without a network connection. Covers local-first data, sync strategies, conflict resolution, optimistic UI, and the patterns that make offline-first feel seamless to users.
Offline-First Mobile Design is the pattern of building mobile applications that remain fully functional regardless of network availability, synchronizing state automatically once connectivity is restored. This approach matters whenever users operate beyond reliable signal range: daily commutes in transit, field work in rural installations, or simply navigating buildings with poor cellular penetration. The objective is zero-data-loss operation and responsive user interfaces that do not block on round-trip latency. This reference page is organized by the specific tasks a mobile engineer faces when implementing this pattern, moving from state detection to conflict resolution and finally to the production sharp edges that catch teams off guard.
The first decision point in any offline-first flow is knowing whether a network request should even be attempted. Most mobile operating systems expose a network status indicator, but the semantics vary. On Android, ConnectivityManager with a NetworkCallback provides detailed links, though a link being “connected” does not guarantee internet access (captive portals are a classic failure mode). On iOS the equivalent is NWPathMonitor from the Network framework, whose pathUpdateHandler reports interface type and whether the path is constrained or expensive; the older NetworkExtension framework is for building VPN and content-filter extensions, not for reading connectivity. The simplest cross-platform primitive remains the navigator.onLine check available in WebViews or hybrid environments — bearing in mind it reports link state, not reachability, and so inherits the same captive-portal blind spot.
From a scriptable perspective, the standard tool for probing connectivity is curl. The -I flag issues a HEAD request, and the --fail flag ensures the exit code reflects HTTP errors. A practical shell idiom to test reachability before launching an app-level fetch looks like this:
if curl -I --fail https://api.example.com/health > /dev/null 2>&1; then
# Network is reachable; proceed with data fetch
echo "Network available, initiating sync"
else
# Network unavailable; switch to offline mode
echo "No network detected; serving cached state only"
fi
Within application code, the pattern is to check this status immediately before dispatching a request. A common anti-pattern is firing the request and handling the error downstream; this wastes battery cycles and degrades the user experience with spinners that never resolve. Instead, the app should branch: if online, perform the fetch and store the result locally; if offline, serve the last-known-good state from the local store and mark the fetch as “pending.” This branching logic must be atomic with respect to the UI thread to avoid race conditions where a late-arriving network response overwrites fresh local input.
Once connectivity is assessed, the next task is ensuring data is available instantly, without waiting for a server round-trip. The universal standard for local persistence in mobile engineering is SQLite. While many products wrap this in ORMs, the underlying mechanism is a relational file format that supports transactions, indexing, and concurrent reads.
A minimal schema for an offline-first cache might look like this:
CREATE TABLE IF NOT EXISTS local_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
The updated_at column is the linchpin for sync logic; it stores a timestamp or version integer representing when the local value was last calculated or received. To insert or update a record atomically, the INSERT OR REPLACE statement is the concrete command:
INSERT OR REPLACE INTO local_cache (key, value, updated_at)
VALUES ('user_profile', '{"name": "Alice", "role": "admin"}', CAST(strftime('%s', 'now') AS INTEGER));
Reading for display is a straightforward select:
SELECT value FROM local_cache WHERE key = 'user_profile';
For applications requiring structured relationships rather than key-value pairs, the same SQL patterns apply. Tables for orders, inventory, or preferences are created with foreign keys and indexes, and the WHERE clauses in application code remain unchanged. The concrete takeaway is that the SQL dialect does not need to be product-specific; any SQLite-compatible engine—whether embedded in the OS, provided by a framework, or bundled into the APK/IPA—will accept these statements. The pattern of using a last_modified timestamp column to detect changes is the generic mechanism that applies across the landscape.
The defining feature of offline-first is not just reading data locally, but writing locally and reconciling those writes when the network returns. The standard pattern for this is the “Outbox” table. Mutations—creates, updates, deletes—are inserted into this table with a status column set to 'pending' before the HTTP request is attempted.
A typical schema for an outbox might be:
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
record_key TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL
);
When a user saves a change offline, the application performs two actions in a single transaction: insert the new record into the authoritative local tables (so the UI updates immediately) and insert a row into the outbox.
BEGIN TRANSACTION;
INSERT INTO orders (customer_id, total, status) VALUES (123, 49.99, 'pending');
INSERT INTO outbox (table_name, record_key, payload, created_at)
VALUES ('orders', 'new_order_42', '{"customer_id": 123, "total": 49.99}', CAST(strftime('%s', 'now') AS INTEGER));
COMMIT;
The application then has a background sync process—often triggered by a connectivity callback or a periodic timer—that iterates over pending outbox entries. For each entry, it constructs and sends an HTTP request. A concrete curl command representing a PATCH request to update an order might look like this:
curl -X PATCH https://api.example.com/orders/new_order_42 \
-H "Content-Type: application/json" \
-d '{"status": "shipped"}' \
-w "\nHTTP_CODE:%{http_code}\n"
Upon receiving a successful response (typically a 2xx status code), the sync process updates the outbox row’s status to 'synced' and deletes the row, or marks it as 'failed' if the response indicates a conflict or server error. If the network drops mid-request, the row remains 'pending', and the next sync attempt will retry it. This idempotent pattern—insert, send, mark synced—ensures that no mutation is lost, even if the app is killed or the device restarts before the sync completes.
The most complex task in offline-first design is conflict resolution. When two devices edit the same record independently while offline, a naive “last write wins” approach based on server timestamps discards legitimate user input. A robust generic mechanism uses version vectors or Lamport timestamps.
Every local record carries a version identifier. In its simplest form, this is an integer counter incremented with every local mutation. A more resilient approach uses globally unique identifiers (UUIDs) for each edit, allowing the server to reconstruct the edit history.
When the sync process fetches the current state from the server, it compares the local version against the server version. If the local version is greater, the client pushes its local state upward. If the server version is greater—perhaps because another device updated the record while this device was offline—the client must decide how to merge.
A common concrete pattern for three-way merge is:
An error text that a developer might actually encounter in a log, representing a conflict that the automated merge could not resolve, might read:
Conflict detected for record_id=88: local_version=5, server_version=7. Automatic merge aborted. Requires user resolution.
This is not tied to a specific product’s error handling; it is the generic shape of the message an engineer would write when implementing a merge routine that detects version divergence.
Implementing the pattern described above is straightforward in a greenfield project, but production deployments reveal edge cases that are easy to overlook.
Stale reads masquerading as fresh data. If the app serves cached data without indicating its age, users may act on information that is hours or days old. A sharp edge is the omission of a “last updated” badge. The engineer must explicitly decide: is the cached value “good enough,” or must the app show a warning banner? The code pattern to surface age is simple—select the updated_at column and compare it against the current time—but the UI decision is where many offline-first apps fail their users.
The “revolving door” sync loop. If the network connectivity callback fires rapidly—connect, disconnect, connect—the outbox sync process may attempt to push the same mutation multiple times, creating duplicate records on the server if the server is not idempotent. The concrete fix is ensuring the curl command includes an Idempotency-Key header, or that the server rejects duplicate record_key inserts. The sync loop should also check the outbox status before retrying; a common bug is re-reading all rows with status = 'pending' without a LIMIT or WHERE clause that excludes recently synced entries, leading to a thundering herd of requests as soon as connectivity returns.
Battery and CPU overhead from polling. Some implementations poll navigator.onLine or ConnectivityManager every few seconds to keep the UI in sync. On mobile devices, frequent system calls to query network state can keep the radio active, draining battery. The pattern that survives production is to rely on the OS-provided callbacks (which are event-driven rather than polling) and to debounce any UI updates. If manual polling is necessary, a interval longer than 30 seconds is the practical minimum, and even then, the radio wake-ups accumulate.
SQLite WAL mode and storage pressure. SQLite’s Write-Ahead Logging (WAL) mode is essential for concurrent read/write performance in an offline-first app, but it creates a *-wal file alongside the database. If the sync process frequently inserts and deletes outbox rows without running PRAGMA wal_checkpoint(TRUNCATE), the WAL file can grow unbounded, consuming device storage. The concrete command to mitigate this, issued from a background sync job, is:
PRAGMA wal_checkpoint(TRUNCATE);
This command forces the WAL content back into the main database file and truncates the WAL, preventing storage exhaustion on long-running devices.
Authentication tokens expiring offline. A subtle but critical edge is the handling of auth tokens. If a user logs in online, receives a JWT, and then the network drops, the app may attempt to use that token for offline writes. If the token has a short TTL (e.g., 15 minutes) and the offline session lasts longer, the server will reject the request with 401 Unauthorized, even though the network is down. The pattern to resolve this is to store the token’s expiry timestamp alongside the outbox entries. Before retrying a failed request, the sync process checks the current time against the stored expiry. If expired, the user must be re-prompted for credentials the moment connectivity returns, rather than silently looping on 401 errors.
Offline-First Mobile Design reduces to a cycle of four concrete tasks: check network status via OS primitives or curl; persist reads and writes in a local SQLite database with versioned timestamps; queue mutations in an outbox table and dispatch them via curl-style HTTP requests when online; and reconcile server and local states using version comparisons, presenting conflicts to the user when automated merge is impossible. The sharp edges—stale data displays, sync loops, battery-draining polling, WAL storage bloat, and token expiry—are the differences between a prototype that works in a lab and a production system that survives the unpredictability of real-world networks. The patterns described here—SQL schemas, curl request forms, status-column tracking—are the universal tools; the specific product wrappers (Realm, Core Data, Firestore) are layerings on top of these mechanics, not replacements for them.
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