Offline-First: Building Apps That Work Without Internet
Most mobile apps are built with an implicit assumption: the network is available. API calls are made on user actions, loading states appear while waiting for responses, errors are shown when requests fail. The user on a train, in an elevator, or in a building with spotty WiFi gets degraded or broken functionality.
Offline-first is a design approach that inverts this assumption: the local device is the primary data store, and the network is used to sync, not to serve every interaction.
The Spectrum
“Offline-first” isn’t binary. There’s a spectrum:
Offline-capable: the app stores some data locally so it can read while offline, but writes fail. Better than nothing, but frustrating when users try to do anything.
Optimistic offline: the app immediately applies writes locally and syncs to the server when connectivity returns. User experience is seamless; writes feel instant. Conflicts need to be handled when sync happens.
Offline-first: the local database is the source of truth. The server is a sync target. The app works fully offline; sync is a background process, not a prerequisite.
Most apps don’t need the full offline-first model. But moving further along this spectrum almost always improves the user experience even for users with good connectivity, because local reads are faster than network reads.
Local Storage Options
The choice of local storage technology determines how much data you can store and how you query it.
On iOS:
- Core Data - Apple’s object graph framework with SQLite backing. Mature, integrates well with SwiftUI, complex to learn.
- SwiftData - Apple’s newer, Swift-native replacement for Core Data. Cleaner API, iOS 17+.
- GRDB - SQLite wrapper with a nice Swift API. Good for teams comfortable with SQL.
- Realm - cross-platform embedded database (iOS + Android). Sync capabilities with Atlas App Services.
On Android:
- Room - Android’s official SQLite abstraction. Type-safe queries via annotations, integrates with LiveData and Flow.
- DataStore - for preferences and small structured data, replacing SharedPreferences.
- Realm - same as iOS.
Cross-platform (React Native, Flutter):
- WatermelonDB - built for React Native, lazy loading, sync-ready.
- Drift (Flutter) - SQLite-based, reactive queries.
- SQLite directly - available on both platforms, lowest level, most control.
The Sync Problem
Syncing is where offline-first gets hard. Questions you have to answer:
What happens when two clients modify the same data while offline? User edits a note on their phone while offline. They also edit it on their tablet while offline. Both sync to the server. Which version wins?
The naive answer (last write wins by timestamp) is wrong. Clocks on devices aren’t synchronized. A device with a slightly wrong clock could cause old changes to overwrite new ones.
What happens when data is deleted on one device while being edited on another? Your note app deletes a note on device A while device B is in the middle of editing it.
How do you handle the case where a write that was valid offline becomes invalid when synced? User reserved the last seat at a show while offline. When sync happens, the seat is already taken.
Conflict Resolution Strategies
Last Write Wins with vector clocks: instead of wall clock timestamps, use logical clocks that track causality. A change from device A after device B has synced is definitely “after.” This is more reliable than timestamps but adds implementation complexity.
CRDTs (Conflict-free Replicated Data Types): data structures designed to merge automatically without conflicts. A counter that only increments (G-Counter) can be merged by taking the max. A set that only adds (G-Set) merges by taking the union. More complex CRDTs handle richer data types. The tradeoff: they limit what data structures you can use.
Operational transformation: track operations (insert character at position 5, delete character at position 3) and transform operations against each other to maintain intent when applied out of order. This is how collaborative text editors work.
Application-specific conflict resolution: for many apps, conflicts are rare enough that you can detect them and prompt the user to resolve manually, or keep both versions as separate records.
A Practical Pattern: Optimistic Updates with Sync Queue
For many apps, this pattern covers most needs:
- User takes an action (creates a todo, sends a message).
- Apply the change immediately to the local database and update the UI.
- Add the operation to a sync queue.
- A background worker processes the sync queue when connectivity is available, sending changes to the server.
- Server responses update the local database (resolving conflicts if any).
// iOS - simplified optimistic update
func createTodo(_ title: String) {
let todo = Todo(id: UUID(), title: title, synced: false)
// 1. Apply locally immediately
localDatabase.insert(todo)
// 2. Queue for sync
syncQueue.enqueue(.createTodo(todo))
}
// Background sync
func processSyncQueue() {
for operation in syncQueue.pending {
do {
try await api.sync(operation)
syncQueue.markComplete(operation)
localDatabase.markSynced(operation.itemId)
} catch {
// Retry with backoff
syncQueue.markFailed(operation)
}
}
}
Handling Stale Data
Offline-first apps read from local storage, which may be out of date. The question is: how out of date is acceptable?
For most apps, showing data that’s a few minutes old with an indicator (“last synced 3 minutes ago”) is fine. For some apps (collaborative real-time editing, financial data), it’s not.
Design your UI to reflect sync state. Show when data was last updated. If an item was modified locally and not yet synced, indicate that. If a sync failed, let the user know and offer a retry. Don’t hide the eventual consistency model from users - they’ll infer it from behavior anyway.
The Real Benefit
The case for offline-first isn’t primarily about supporting users with no connectivity. It’s about making your app faster and more resilient for all users.
Local reads are measured in microseconds. Network reads are measured in hundreds of milliseconds. An offline-first app that reads from local storage and syncs in the background feels dramatically faster than one that blocks on network requests for every interaction.
The user who benefits most from offline-first is the one with a slow connection - which is most mobile users, most of the time. The subway commuter, the user in a rural area, the person in a crowded venue with saturated WiFi. Building for the degraded case builds a better product for everyone.