Back to all articles

Offline-First Mobile Apps: Architecture, Sync, and Implementation

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.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.