How to Build a Fast Offline‑First Android App Using Jetpack Compose and Room

You’ve probably opened an app on a train, only to watch it grind to a halt when the signal drops. That frustration is real, and it’s why building offline‑first apps is no longer a nice‑to‑have—it’s a must. In this walkthrough I’ll show you, step by step, how to create a snappy Android app that works even when the network says “no thanks.” We’ll use Jetpack Compose for the UI and Room for local storage, two tools I love because they let you focus on the experience, not the boilerplate.

Why Offline‑First Matters

Most users expect an app to be usable anywhere, anytime. If your app stalls the moment a Wi‑Fi network disappears, you lose trust faster than a bad coffee shop loses customers. Offline‑first means the app stores data locally first, then syncs with the server when a connection is available. The benefits are clear:

  • Speed – Reading from the device is orders of magnitude faster than a round‑trip to a server.
  • Reliability – Users can still view and edit data when they’re on a subway or in a remote area.
  • Battery savings – Fewer network calls mean less power drain.

All of this sounds great, but the devil is in the details. That’s where Jetpack Compose and Room step in.

Understanding Jetpack Compose

Jetpack Compose is Google’s modern UI toolkit that lets you describe your UI in Kotlin code, not XML. Think of it as building a UI with LEGO bricks: each composable function is a brick, and you snap them together to create screens.

  • Composable – A function annotated with @Composable. It tells Compose how to draw something on the screen.
  • State – Data that, when changed, automatically re‑renders the UI. Compose watches state and updates the UI for you.

Because Compose is tightly integrated with Kotlin coroutines, handling asynchronous data (like loading from a database) feels natural. No more juggling callbacks or LiveData just to keep the UI in sync.

Getting Started with Room

Room is the official SQLite wrapper from Android Jetpack. It gives you a type‑safe way to store and query data without writing raw SQL all the time.

Key concepts:

  • Entity – A data class that represents a table in the database.
  • Dao – Data Access Object; an interface that defines methods for reading and writing.
  • Database – The abstract class that ties entities and DAOs together.

Room works well with Kotlin coroutines, so you can call suspend functions to read or write data without blocking the UI thread.

Setting Up the Project

  1. Create a new Compose project – In Android Studio, choose “Empty Compose Activity.” This gives you a baseline MainActivity with a setContent block.
  2. Add dependencies – In build.gradle (app level) add:
implementation "androidx.compose.ui:ui:1.5.0"
implementation "androidx.compose.material:material:1.5.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.0"
implementation "androidx.room:room-runtime:2.5.2"
kapt "androidx.room:room-compiler:2.5.2"
implementation "androidx.room:room-ktx:2.5.2"

Make sure you apply the kotlin-kapt plugin for annotation processing.

  1. Create the Entity – Let’s say we’re building a simple notes app.
@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val content: String,
    val timestamp: Long = System.currentTimeMillis()
)
  1. Define the DAO – Basic CRUD operations.
@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY timestamp DESC")
    fun getAllNotes(): Flow<List<Note>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(note: Note)

    @Delete
    suspend fun delete(note: Note)
}

Notice the return type of getAllNotes() is a Flow. This is a cold stream that emits new lists whenever the database changes—perfect for Compose.

  1. Build the Database – A singleton pattern keeps things simple.
@Database(entities = [Note::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao

    companion object {
        @Volatile private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "notes_db"
                ).fallbackToDestructiveMigration().build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Wiring Compose to Room

Now that the data layer is ready, let’s bring it into the UI.

@Composable
fun NoteListScreen(viewModel: NoteViewModel = viewModel()) {
    val notes by viewModel.notes.collectAsState(initial = emptyList())

    Scaffold(
        topBar = { TopAppBar(title = { Text("My Offline Notes") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = { viewModel.showAddDialog() }) {
                Icon(Icons.Default.Add, contentDescription = "Add")
            }
        }
    ) {
        LazyColumn {
            items(notes) { note ->
                NoteItem(note = note, onDelete = { viewModel.deleteNote(it) })
            }
        }
    }
}

The collectAsState extension turns the Flow from Room into a Compose State. Whenever the database updates, the UI recomposes automatically—no extra wiring needed.

The ViewModel

A ViewModel keeps the UI logic separate from the Activity.

class NoteViewModel(application: Application) : AndroidViewModel(application) {
    private val dao = AppDatabase.getInstance(application).noteDao()
    val notes = dao.getAllNotes().stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

    fun addNote(title: String, content: String) {
        viewModelScope.launch {
            dao.insert(Note(title = title, content = content))
        }
    }

    fun deleteNote(note: Note) {
        viewModelScope.launch {
            dao.delete(note)
        }
    }

    // UI helper for showing a dialog – omitted for brevity
    fun showAddDialog() { /* ... */ }
}

Using stateIn converts the Flow into a StateFlow, which works nicely with Compose’s collectAsState.

Syncing with the Server

Offline‑first does not mean “never sync.” You still want a cloud backup. The pattern I use is:

  1. Write locally first – All inserts and deletes go to Room.
  2. Queue a sync job – Use WorkManager to schedule a background task that runs when the device has network.
  3. Resolve conflicts – Simple apps can use “last write wins.” More complex scenarios need a merge strategy.

Here’s a tiny WorkManager example:

class SyncWorker(appContext: Context, params: WorkerParameters) :
    CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        val dao = AppDatabase.getInstance(applicationContext).noteDao()
        val unsynced = dao.getUnsyncedNotes() // you’d add a flag to the entity
        // Call your remote API here
        // On success, mark notes as synced
        return Result.success()
    }
}

Schedule it with constraints:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .build()

val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "notes_sync",
    ExistingWorkPolicy.KEEP,
    syncRequest
)

Because the UI never waits for the network, the app feels instant.

Testing Offline Performance

A fast offline experience is not just about code; it’s about measuring. Here’s what I do:

  • Profile with Android Studio – The CPU and Memory profilers show you if Room queries are taking too long.
  • Use adb shell dumpsys activity – It tells you if your app is being killed often, which can indicate memory pressure.
  • Simulate network loss – In the emulator, toggle “Airplane mode” and verify the UI still works.

If you notice lag, consider adding indexes to your Room tables. For example:

@Entity(tableName = "notes", indices = [Index(value = ["timestamp"])])

Indexes make sorting by timestamp fast, which is exactly what our note list does.

Tips and Gotchas

  • Don’t block the main thread – Even though Room supports coroutines, calling a suspend function from the UI without launch will freeze the screen.
  • Keep the database small – If you store large blobs (images, PDFs), move them to the file system and keep only a reference in Room.
  • Handle migrations – When you change the schema, write a proper migration instead of relying on fallbackToDestructiveMigration in production.
  • Use rememberSaveable – For transient UI state (like text field content) that you want to survive process death, rememberSaveable works like a charm.
  • Watch out for “stale” data – If your sync job fails, you might end up with notes that never reach the server. Show a subtle “pending sync” badge so users know the status.

Wrap‑Up

Building an offline‑first Android app with Jetpack Compose and Room is surprisingly straightforward once you break it into pieces: define your data model, let Room handle persistence, let Compose render the UI, and let WorkManager take care of background sync. The result is an app that feels fast, reliable, and ready for the real world—whether the user is on a high‑speed Wi‑Fi or stuck in a tunnel.

Happy coding, and may your apps stay speedy even when the network doesn’t.

Reactions