Accent

πŸ‘‹ Download my CV
Download CV
All Articles
Building Offline-First Mobile Apps with Kotlin & Room
Full-Stack Jan 2025 Β· 8 min read

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:

StateMeaning
SYNCEDServer and local are in agreement
PENDINGLocal change waiting to be pushed
CONFLICTServer 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

  1. Don’t sync everything at once. Batch records in chunks of 50 to avoid timeouts.
  2. Idempotency keys on your API prevent duplicate inserts on retry.
  3. Separate sync state from domain state β€” your Note model shouldn’t know it’s synced; a separate table tracks that.
  4. 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.