DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Stop Losing Offline User Updates: Build a Room Mutation Queue

Most Android apps handle offline reads using Room as a cache. But when it comes to offline writes (mutations like updates, inserts, or deletes), standard implementations either fail immediately or block the UI until the network returns.

To build a true offline-first app, you need a local transaction write-queue.

Here is a lightweight, production-grade blueprint using Room, Ktor, and WorkManager to buffer and sync offline updates reliably.


1. The Offline Mutation Schema
First, create a SQLite table in Room to store pending mutations. This table captures the type of operation, the target table, and the serialized payload.

@Entity(tableName = "mutation_queue")
data class PendingMutation(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val type: String,          // "INSERT", "UPDATE", "DELETE"
    val targetTable: String,   // e.g., "tasks", "users"
    val payloadJson: String,   // Serialized request body
    val timestamp: Long = System.currentTimeMillis()
)
Enter fullscreen mode Exit fullscreen mode

Create the corresponding Data Access Object (DAO) to write, fetch, and clear queued operations:

@Dao
interface MutationDao {
    @Query("SELECT * FROM mutation_queue ORDER BY timestamp ASC")
    suspend fun getAllPending(): List<PendingMutation>

    @Delete
    suspend fun delete(mutation: PendingMutation)

    @Insert
    suspend fun insert(mutation: PendingMutation)
}
Enter fullscreen mode Exit fullscreen mode

2. Writing to the Queue Natively
When a user performs an update while offline, write the data to the local feature table (so the UI updates immediately) and queue the transaction inside a single database transaction:

suspend fun updateTaskOffline(task: TaskEntity) {
    database.withTransaction {
        // 1. Update the local UI cache table
        taskDao.update(task)

        // 2. Queue the mutation for server sync
        val mutation = PendingMutation(
            type = "UPDATE",
            targetTable = "tasks",
            payloadJson = json.encodeToString(task)
        )
        mutationDao.insert(mutation)
    }
    // 3. Trigger WorkManager to run sync task
    scheduleSyncWorker()
}
Enter fullscreen mode Exit fullscreen mode

3. The WorkManager Synchronization Loop
Now, write a CoroutineWorker that reads the queue and flushes the mutations to your API sequentially.

class SyncWorker(
    context: Context,
    params: WorkerParameters,
    private val db: AppDatabase,
    private val api: KtorClient
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val pendingMutations = db.mutationDao().getAllPending()
        if (pendingMutations.isEmpty()) return Result.success()

        for (mutation in pendingMutations) {
            try {
                // Post payload to Ktor server
                val response = api.post("api/sync") {
                    setBody(mutation.payloadJson)
                }

                // If success, delete from queue
                if (response.status.value in 200..299) {
                    db.mutationDao().delete(mutation)
                }
            } catch (e: Exception) {
                // If API fails or times out, reschedule with exponential backoff
                return Result.retry()
            }
        }
        return Result.success()
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Scheduling the Sync
Set up constraints to ensure the worker only runs when the device has an active network connection, and apply an exponential backoff policy:

fun Context.scheduleSyncWorker() {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()

    val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
        .setConstraints(constraints)
        .setBackoffCriteria(
            BackoffPolicy.EXPONENTIAL,
            10,
            TimeUnit.SECONDS
        )
        .build()

    WorkManager.getInstance(this).enqueueUniqueWork(
        "OfflineSyncWork",
        ExistingWorkPolicy.KEEP, // Keep existing sync task in queue, don't interrupt
        syncWorkRequest
    )
}
Enter fullscreen mode Exit fullscreen mode

The Result

  1. The user updates data offline.
  2. The UI changes immediately (caching).
  3. The mutation is saved in Room.
  4. WorkManager triggers the moment connection returns, uploading the queue chronologically without user intervention.

Open-Source Reference
This implementation is part of the open-source Android System Design & Architecture Checklist. You can clone the full repository of offline-first configurations, convention plugins, and secure Keystore setups here:

👉 GitHub: Android System Design & Architecture Checklist (A print-ready, high-resolution 12-page PDF version is also pinned in the repository description).

Top comments (0)