Android with Kotlin and Jetpack Compose · Architecture and Data: ViewModels, Navigation, Networking and Offline

Room and Offline-First Data

Store data on the device with Room, observe it as a Flow, and build an offline-first repository: the UI reads the local database, and sync keeps it up to date with the server.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A notes app that opens instantly with the notes saved on the device, lets you add and edit notes with no connection, and syncs changes with the server when it's back online.

The idea

Offline-first means the local database is the single source of truth for the UI:

  1. The screen observes the database (a Flow from Room), so it always shows saved data, even offline.
  2. A refresh fetches from the network and writes into the database; the Flow then emits the new data automatically.
  3. Local edits are saved immediately and marked as pending; a sync job sends them later and resolves conflicts (for example, last write wins by updatedAt).

Room is the SQLite library from Jetpack: you declare entities (tables), DAOs (queries, checked at compile time) and a database class. DAO functions can return Flow to observe changes, or be suspend for one-off reads and writes.

The code

@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey val id: String,
    val text: String,
    val updatedAt: Long,
    val pendingSync: Boolean = false,
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY updatedAt DESC")
    fun observeAll(): Flow<List<NoteEntity>>

    @Query("SELECT * FROM notes WHERE pendingSync = 1")
    suspend fun pending(): List<NoteEntity>

    @Upsert
    suspend fun upsert(notes: List<NoteEntity>)
}

@Database(entities = [NoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}

class NoteRepository(private val dao: NoteDao, private val api: NotesApi) {
    val notes: Flow<List<Note>> = dao.observeAll().map { rows -> rows.map { it.toDomain() } }

    suspend fun save(id: String, text: String, now: Long) =
        dao.upsert(listOf(NoteEntity(id, text, now, pendingSync = true)))

    suspend fun sync() {
        val pending = dao.pending()
        api.push(pending.map { it.toDto() })                      // send local edits first
        val remote = api.changedSince(lastSyncTime())
        dao.upsert(merge(remote, pending))                          // then apply server changes
    }
}

Add Room with KSP: ksp("androidx.room:room-compiler:2.8.5") and implementation("androidx.room:room-ktx:2.8.5"). Increase version and add a migration whenever the schema changes, or users lose data.

Try the logic

The repository's merge rule (last write wins, and unsynced local edits are never lost) as runnable code:

Merging server changes with local edits
data class Note(val id: String, val text: String, val updatedAt: Long, val pendingSync: Boolean = false)

fun merge(local: List<Note>, remote: List<Note>): List<Note> {
    val byId = local.associateBy { it.id }.toMutableMap()
    for (r in remote) {
        val l = byId[r.id]
        byId[r.id] = when {
            l == null -> r                                          // new on the server
            l.pendingSync && l.updatedAt > r.updatedAt -> l         // our newer edit wins, still to be pushed
            else -> r.copy(pendingSync = false)                     // server is newer
        }
    }
    return byId.values.sortedByDescending { it.updatedAt }
}

fun main() {
    val local = listOf(
        Note("a", "Buy milk (edited offline)", updatedAt = 300, pendingSync = true),
        Note("b", "Call Ana", updatedAt = 100),
    )
    val remote = listOf(
        Note("a", "Buy milk", updatedAt = 200),
        Note("b", "Call Ana at 5pm", updatedAt = 250),
        Note("c", "Book flights", updatedAt = 150),
    )
    merge(local, remote).forEach { println("${it.id} | ${it.text} | ${it.updatedAt} | pending=${it.pendingSync}") }
}

Output

a | Buy milk (edited offline) | 300 | pending=true
b | Call Ana at 5pm | 250 | pending=false
c | Book flights | 150 | pending=false

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Showing network results directly. The UI flickers and is empty offline; write to the database and let the UI observe it.
  • Forgetting migrations. Changing an entity without bumping the version and providing a Migration crashes on upgrade (or wipes data with destructive fallback).
  • Queries on the main thread. Room rejects them by default; use suspend functions and Flow.
  • Ignoring conflicts. Decide a rule (last write wins, merge fields, or ask the user) before two devices edit the same record.

Key points

  • Offline-first: the local database is the source of truth; the network refreshes it.
  • Room gives compile-time-checked SQL, Flow queries and suspend writes.
  • Mark local edits as pending and sync them later with a clear conflict rule.
  • Plan schema migrations from the first release.

Further reading: Build an offline-first app, Room.

Practice questions

What does offline-first mean for an Android app's architecture?

Answer

The UI reads from a local database (the single source of truth), which is observed as a Flow. Network requests only update that database, and local changes are written locally first and synced later, so the app works and stays consistent without a connection.

What happens if you change a Room entity without a migration?

Answer

When an installed app with the old schema opens the database, Room detects the version or schema mismatch and throws, crashing the app, unless you configured destructive migration, which deletes the user's data. Bump the version and provide a Migration (or an auto-migration).

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?