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()
)
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)
}
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()
}
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()
}
}
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
)
}
The Result
- The user updates data offline.
- The UI changes immediately (caching).
- The mutation is saved in Room.
- 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)