Building Offline-First Mobile Apps with Kotlin & Room
A deep dive into designing apps that work seamlessly without an internet connection using SQLite, WorkManager, and sync strategies.
Why Offline-First Matters
Modern users expect apps to work even with spotty connectivity β on the subway, in rural areas, or during travel abroad. An offline-first architecture treats network access as an enhancement, not a requirement.
In my Kotlin projects Iβve shipped this pattern by layering three ingredients: Room for local persistence, WorkManager for deferred background sync, and a clean Repository abstraction that shields the UI from network state entirely.
The Core Architecture
// Repository decides: serve local data first, then sync in background
class NoteRepository(
private val dao: NoteDao,
private val api: NoteApiService,
private val workManager: WorkManager
) {
fun getNotes(): Flow<List<Note>> = dao.getAllNotes() // always from Room
suspend fun createNote(note: Note) {
dao.insert(note.copy(syncState = SyncState.PENDING))
workManager.enqueueUniqueWork(
"sync_notes",
ExistingWorkPolicy.REPLACE,
SyncWorker.buildRequest()
)
}
}
The key insight: write to Room immediately, sync to the server later. The UI never waits for the network.
Sync State Machine
Each entity carries a syncState column that drives WorkManagerβs behavior:
| State | Meaning |
|---|---|
SYNCED | Server and local are in agreement |
PENDING | Local change waiting to be pushed |
CONFLICT | Server has a newer version |
WorkManager retries automatically with exponential backoff, and when connectivity returns, the SyncWorker processes all PENDING rows.
Conflict Resolution
Last-write-wins with server timestamps is the simplest strategy and covers ~90% of use-cases. For collaborative apps, implement vector clocks or operational transforms β but thatβs a whole other article.
suspend fun resolveConflict(local: Note, remote: Note): Note =
if (remote.updatedAt > local.updatedAt) remote else local
What I Learned
- Donβt sync everything at once. Batch records in chunks of 50 to avoid timeouts.
- Idempotency keys on your API prevent duplicate inserts on retry.
- Separate sync state from domain state β your
Notemodel shouldnβt know itβs synced; a separate table tracks that. - Roomβs
Flow+ Kotlin coroutines makes reactive UI trivial once the repository is solid.
Offline-first feels complex upfront but dramatically improves user trust. Your users notice when an app works at 2 bars of signal β and they remember it.
Found this useful? Share it.