Offline-First Mobile App Architecture: Syncing, Caching, and Conflict Resolution
Odunayo Dada

Odunayo Dada @odunayo_dada

About: Software engineer (Kotlin, React Native, Node.js, Flask). I build real-world apps with clean code. Exploring system design. YouTube: OdunCodes | GitHub: github.com/dada-odunayo

Location:
Lagos, Nigeria
Joined:
Dec 30, 2024

Offline-First Mobile App Architecture: Syncing, Caching, and Conflict Resolution

Publish Date: Jul 16
-1 0

In many parts of the world, network connectivity is unreliable. Even in major cities, mobile users frequently lose signal while commuting, entering buildings, or during power outages.

If your app stops working the moment internet access is lost, you’re building for ideal conditions , not the real world.

Mobile App with no network

Offline-First Design
An offline-first application is designed to work seamlessly with or without the internet. It uses local storage as the primary data source and synchronizes changes with a remote server once connectivity is available.

This approach ensures your users can:

  • Continue working uninterrupted

  • Avoid data loss

  • Trust your app to be available at all times

Real-World Use Case: Field Data Collection in Rural Areas
I will share my approach to building a field data collection app for areas with poor internet. I had to ensure that:

  • User input was never lost
  • Data could be submitted at any time — whether online or not
  • The app could sync data automatically once network resumed.

Here’s how I implemented this using Room, WorkManager, and NetworkCallback in Android.

Persisting Data Locally with Room
Whenever a user captures data (e.g., survey responses or inspection records), it is saved in a local SQLite database using Room.

@Entity(tableName = "field_data")
data class FieldDataEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val formName: String,
    val content: String,
    val isSynced: Boolean = false,
    val timestamp: Long = System.currentTimeMillis()
)

@Dao
interface FieldDataDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(fieldData: FieldDataEntity)

    @Query("SELECT * FROM field_data WHERE isSynced = 0")
    suspend fun getUnsyncedData(): List<FieldDataEntity>

    @Query("UPDATE field_data SET isSynced = 1 WHERE id = :id")
    suspend fun markAsSynced(id: String)
}
Enter fullscreen mode Exit fullscreen mode

Syncing with the Server Using WorkManager:

Once the device regains network access, a background worker is triggered to sync all unsynced data in the room database.

class DataSyncWorker(
    context: Context,
    workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {

    private val dao = AppDatabase.getInstance(context).fieldDataDao()
    private val api = ApiService.create() 

    override suspend fun doWork(): Result {
        val unsynced = dao.getUnsyncedData()
        for (item in unsynced) {
            try {
                val response = api.uploadFieldData(item)
                if (response.isSuccessful) {
                    dao.markAsSynced(item.id)
                    sendInAppNotification("Data synced: ${item.formName}")
                }
            } catch (e: Exception) {
                // Retry later
            }
        }
        return Result.success()
    }

    private fun sendInAppNotification(message: String) {
        // Optionally notify user
    }
}
Enter fullscreen mode Exit fullscreen mode

Detecting Network Availability

Used Connectivity Manager to detect when the device regains internet access and enqueue the sync worker.

fun registerNetworkCallback(context: Context) {
    val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val request = NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build()

    connectivityManager.registerNetworkCallback(request, object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            enqueueSyncWorker(context)
        }
    })
}

fun enqueueSyncWorker(context: Context) {
    val request = OneTimeWorkRequestBuilder<DataSyncWorker>()
        .setConstraints(Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build())
        .build()

    WorkManager.getInstance(context).enqueue(request)
}
Enter fullscreen mode Exit fullscreen mode

Conflict Resolution Strategy
In my case, conflicts were minimal because users didn’t edit the same record from multiple devices. However, in more collaborative apps, you can handle conflicts by:

Last-write-wins: simplest, but risky
Merge strategies: combine changes from client + server
User-assisted: notify user to choose the correct version
User Feedback: In-App and Push Notifications

Once sync completes, we notify the user via:

In-app snackbar/toast (if app is foregrounded)
Push notification using a local notification

Key Lessons:
Prioritize local-first design when working in regions with poor internet
Always queue unsynced data instead of blocking the user
Use WorkManager + Room + NetworkCallback for resilient, testable sync logic.
Don’t forget about conflict resolution — design for edge cases

Have you built offline-first apps before? I’d love to hear how you approached syncing and caching in the comments.

Comments 0 total

    Add comment