Apps that work offline have 40% better retention. Users expect apps to function regardless of connectivity. This guide covers building robust offline-first mobile applications.
Why Offline-First?
- Works in poor connectivity (subway, airplane, rural areas)
- Faster performance (local data access)
- Better user experience (no loading spinners)
- Higher engagement and retention
- Reduced server load
- Resilient to network failures
Offline-First Architecture
Core Principles
1. Local-First
- All operations work on local data
- App functions without internet
- Sync in background when available
2. Optimistic Updates
- UI updates immediately
- Sync happens later
- Rollback on failure
3. Eventual Consistency
- Local and server will sync eventually
- Handle conflicts gracefully
- Display sync status to user
Data Flow:
User Action → Local Database → Update UI
→ Queue Sync → Server (when online)
Server Response → Resolve Conflicts → Update Local
Architecture Layers
┌─────────────────────────────────┐
│ UI Layer │
│ (SwiftUI / Jetpack Compose) │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ Business Logic Layer │
│ (ViewModels / UseCases) │
└──────────────┬──────────────────┘
│
┌──────────────▼──────────────────┐
│ Data Repository Layer │
│ (Combine local + remote data) │
└──────────────┬──────────────────┘
│
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Local │ │ Remote │
│ Store │ │ API │
└──────────┘ └──────────┘
Local Storage Options
iOS Storage
Options:
1. Core Data (recommended for offline-first)
✓ Full-featured ORM
✓ Relationships and migrations
✓ iCloud sync support
✓ NSFetchedResultsController for lists
2. Realm
✓ Fast and easy
✓ Reactive updates
✓ Built-in sync (paid)
3. SQLite (via GRDB or FMDB)
✓ Direct SQL control
✓ Lightweight
4. SwiftData (iOS 17+)
✓ Modern Swift-first API
✓ Built on Core Data
Core Data Implementation
import CoreData
// 1. Define Model
@objc(Post)
public class Post: NSManagedObject {
@NSManaged public var id: String
@NSManaged public var title: String
@NSManaged public var content: String
@NSManaged public var createdAt: Date
@NSManaged public var syncStatus: String // "synced", "pending", "conflict"
@NSManaged public var lastSyncedAt: Date?
}
// 2. Core Data Stack
class CoreDataStack {
static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Core Data error: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
var context: NSManagedObjectContext {
persistentContainer.viewContext
}
func saveContext() {
if context.hasChanges {
do {
try context.save()
} catch {
print("Save error: \(error)")
}
}
}
}
// 3. Repository
class PostRepository {
private let context = CoreDataStack.shared.context
func getAllPosts() -> [Post] {
let request: NSFetchRequest = Post.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
return (try? context.fetch(request)) ?? []
}
func createPost(title: String, content: String) -> Post {
let post = Post(context: context)
post.id = UUID().uuidString
post.title = title
post.content = content
post.createdAt = Date()
post.syncStatus = "pending"
CoreDataStack.shared.saveContext()
// Queue for sync
SyncManager.shared.queueSync(for: post)
return post
}
func updatePost(_ post: Post, title: String?, content: String?) {
if let title = title { post.title = title }
if let content = content { post.content = content }
post.syncStatus = "pending"
CoreDataStack.shared.saveContext()
SyncManager.shared.queueSync(for: post)
}
func deletePost(_ post: Post) {
post.syncStatus = "deleted"
CoreDataStack.shared.saveContext()
SyncManager.shared.queueDeleteSync(for: post)
}
}
Android Storage
Options:
1. Room (recommended)
✓ Type-safe SQL wrapper
✓ Compile-time verification
✓ LiveData integration
✓ Migration support
2. Realm
✓ Fast NoSQL
✓ Easy to use
3. SQLite (direct)
✓ Manual control
Room Implementation
// 1. Entity
@Entity(tableName = "posts")
data class Post(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val content: String,
val createdAt: Long = System.currentTimeMillis(),
val syncStatus: SyncStatus = SyncStatus.PENDING,
val lastSyncedAt: Long? = null,
val version: Int = 1
)
enum class SyncStatus {
SYNCED, PENDING, CONFLICT, DELETED
}
// 2. DAO
@Dao
interface PostDao {
@Query("SELECT * FROM posts WHERE syncStatus != 'DELETED' ORDER BY createdAt DESC")
fun getAllPosts(): Flow>
@Query("SELECT * FROM posts WHERE id = :id")
suspend fun getPostById(id: String): Post?
@Query("SELECT * FROM posts WHERE syncStatus = 'PENDING'")
suspend fun getPendingSyncPosts(): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(post: Post)
@Update
suspend fun update(post: Post)
@Delete
suspend fun delete(post: Post)
@Query("UPDATE posts SET syncStatus = :status, lastSyncedAt = :timestamp WHERE id = :id")
suspend fun updateSyncStatus(id: String, status: SyncStatus, timestamp: Long)
}
// 3. Database
@Database(entities = [Post::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun postDao(): PostDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build()
INSTANCE = instance
instance
}
}
}
}
// 4. Repository
class PostRepository(private val postDao: PostDao, private val api: ApiService) {
val posts: Flow> = postDao.getAllPosts()
suspend fun createPost(title: String, content: String): Post {
val post = Post(title = title, content = content, syncStatus = SyncStatus.PENDING)
postDao.insert(post)
syncPost(post)
return post
}
suspend fun updatePost(id: String, title: String, content: String) {
val post = postDao.getPostById(id) ?: return
val updated = post.copy(
title = title,
content = content,
syncStatus = SyncStatus.PENDING,
version = post.version + 1
)
postDao.update(updated)
syncPost(updated)
}
suspend fun deletePost(id: String) {
val post = postDao.getPostById(id) ?: return
val marked = post.copy(syncStatus = SyncStatus.DELETED)
postDao.update(marked)
syncDelete(post)
}
private suspend fun syncPost(post: Post) {
try {
val response = api.createOrUpdatePost(post)
postDao.updateSyncStatus(
post.id,
SyncStatus.SYNCED,
System.currentTimeMillis()
)
} catch (e: Exception) {
// Retry later
SyncWorker.scheduleSync()
}
}
private suspend fun syncDelete(post: Post) {
try {
api.deletePost(post.id)
postDao.delete(post)
} catch (e: Exception) {
SyncWorker.scheduleSync()
}
}
}
Synchronization Strategy
Sync Queue
iOS:
class SyncManager {
static let shared = SyncManager()
private let queue = OperationQueue()
func queueSync(for post: Post) {
let operation = SyncOperation(post: post)
queue.addOperation(operation)
}
func syncAll() {
let posts = PostRepository().getPendingSyncPosts()
posts.forEach { queueSync(for: $0) }
}
}
class SyncOperation: Operation {
let post: Post
init(post: Post) {
self.post = post
}
override func main() {
guard !isCancelled else { return }
let semaphore = DispatchSemaphore(value: 0)
APIService.shared.syncPost(post) { result in
switch result {
case .success:
self.post.syncStatus = "synced"
self.post.lastSyncedAt = Date()
CoreDataStack.shared.saveContext()
case .failure(let error):
print("Sync failed: \(error)")
}
semaphore.signal()
}
semaphore.wait()
}
}
Android:
class SyncWorker(context: Context, params: WorkerParameters)
: CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val repository = (applicationContext as MyApp).postRepository
val pendingPosts = repository.getPendingSyncPosts()
var allSuccess = true
pendingPosts.forEach { post ->
try {
when (post.syncStatus) {
SyncStatus.PENDING -> repository.syncPost(post)
SyncStatus.DELETED -> repository.syncDelete(post)
else -> {}
}
} catch (e: Exception) {
allSuccess = false
}
}
return if (allSuccess) Result.success() else Result.retry()
}
companion object {
fun scheduleSync(context: Context) {
val syncRequest = OneTimeWorkRequestBuilder()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15, TimeUnit.MINUTES
)
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
}
}
}
Conflict Resolution
Conflict types:
1. Last Write Wins
- Simplest strategy
- Server timestamp decides
- Use when conflicts are rare
2. Version Vectors
- Track change history
- Detect concurrent modifications
- Merge changes when possible
3. Custom Resolution
- App-specific logic
- User decides
- Show diff UI
Implementation:
data class ConflictResolution {
val local: Post
val server: Post
fun resolve(): Post {
// Strategy 1: Last Write Wins
return if (local.updatedAt > server.updatedAt) local else server
// Strategy 2: Ask User
// return showConflictDialog(local, server)
// Strategy 3: Merge Fields
// return Post(
// title = if (local.updatedAt > server.updatedAt) local.title else server.title,
// content = mergeDiffs(local.content, server.content)
// )
}
}
Network Detection
iOS Reachability
import Network
class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
@Published var isConnected = false
@Published var connectionType: ConnectionType = .none
enum ConnectionType {
case wifi, cellular, ethernet, none
}
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
self?.connectionType = self?.getConnectionType(path) ?? .none
if path.status == .satisfied {
// Trigger sync
SyncManager.shared.syncAll()
}
}
}
monitor.start(queue: queue)
}
private func getConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
if path.usesInterfaceType(.wiredEthernet) { return .ethernet }
return .none
}
}
// Usage in SwiftUI
struct ContentView: View {
@StateObject private var network = NetworkMonitor.shared
var body: some View {
VStack {
if !network.isConnected {
Text("Offline Mode")
.foregroundColor(.orange)
}
// ... rest of UI
}
}
}
Android Connectivity
class NetworkMonitor(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow = _isConnected.asStateFlow()
init {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isConnected.value = true
SyncWorker.scheduleSync(context)
}
override fun onLost(network: Network) {
_isConnected.value = false
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
fun isOnline(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
Offline UI Patterns
Sync Status Indicators
SwiftUI:
struct PostRow: View {
let post: Post
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(post.title)
Text(post.content)
.foregroundColor(.secondary)
}
Spacer()
// Sync indicator
if post.syncStatus == "pending" {
ProgressView()
} else if post.syncStatus == "synced" {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
} else if post.syncStatus == "conflict" {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
}
}
}
}
Jetpack Compose:
@Composable
fun PostItem(post: Post) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(post.title, style = MaterialTheme.typography.h6)
Text(post.content, style = MaterialTheme.typography.body2)
}
when (post.syncStatus) {
SyncStatus.PENDING -> CircularProgressIndicator(modifier = Modifier.size(24.dp))
SyncStatus.SYNCED -> Icon(Icons.Filled.CheckCircle, null, tint = Color.Green)
SyncStatus.CONFLICT -> Icon(Icons.Filled.Warning, null, tint = Color.Orange)
else -> {}
}
}
}
Optimistic Updates
Pattern
1. User performs action
2. Update local state immediately (optimistic)
3. Update UI instantly
4. Send to server in background
5. On success: mark as synced
6. On failure: rollback or show error
Example (like button):
func toggleLike(post: Post) {
// 1. Optimistic update
post.isLiked.toggle()
post.likesCount += post.isLiked ? 1 : -1
saveLocally(post)
// 2. UI updates automatically (via binding)
// 3. Sync to server
Task {
do {
try await api.toggleLike(postId: post.id)
post.syncStatus = "synced"
saveLocally(post)
} catch {
// Rollback
post.isLiked.toggle()
post.likesCount += post.isLiked ? 1 : -1
saveLocally(post)
showError("Failed to sync like")
}
}
}
Pagination Offline
Strategy
1. Cache first N pages locally
2. Load from cache instantly
3. Fetch newer data in background
4. Merge and update
iOS:
class PostViewModel: ObservableObject {
@Published var posts: [Post] = []
private var currentPage = 1
func loadPosts() {
// 1. Load from cache
posts = repository.getCachedPosts(page: currentPage)
// 2. Fetch from server if online
if NetworkMonitor.shared.isConnected {
Task {
let fresh = try await api.fetchPosts(page: currentPage)
// 3. Update cache
repository.cachePosts(fresh, page: currentPage)
// 4. Update UI
await MainActor.run {
posts = fresh
}
}
}
}
}
Testing Offline
Simulate Offline
iOS Simulator:
- Settings → Developer → Network Link Conditioner
- Or: Xcode → Debug → Simulate Location
Android Emulator:
- Extended controls → Settings → Cellular
- Toggle data on/off
Code:
#if DEBUG
class MockAPIService: APIService {
var forceOffline = false
func fetchPosts() async throws -> [Post] {
if forceOffline {
throw NetworkError.offline
}
// Normal fetch
}
}
#endif
Best Practices
- ✅ Design offline-first from day one
- ✅ Show sync status to users
- ✅ Handle conflicts gracefully
- ✅ Test with real offline scenarios
- ✅ Cache strategically (not everything)
- ✅ Use background sync
- ✅ Provide manual refresh option
- ✅ Clear old cache periodically
Conclusion
Offline-first architecture dramatically improves UX and retention. Store data locally, sync in background, handle conflicts thoughtfully, and always show users the sync state. Apps that work offline feel fast and reliable, leading to happier users and better reviews.