Mobile Development 12 min read

Building Offline‑First Android Apps: Room, WorkManager, Paging 3 & Compose

Learn how to create robust offline‑first Android applications by combining local data storage with Room, background synchronization using WorkManager, efficient pagination via Paging 3, key‑value handling with DataStore, and responsive UI updates through Jetpack Compose, ensuring seamless user experience across unstable network conditions.

AndroidPub
AndroidPub
AndroidPub
Building Offline‑First Android Apps: Room, WorkManager, Paging 3 & Compose

In modern mobile app development, network instability is common; users may be in subways or elevators with poor or no signal. An offline‑first app that continues to function enhances user experience and product competitiveness.

1. Local Data Storage: Room

Room is the official Android ORM for structured relational data, providing reliable local storage. By storing data locally first, the app can operate without network, e.g., saving posts in a social app locally and syncing later.

@Entity(tableName = "posts")
data class PostEntity(
    @PrimaryKey val id: String,
    val title: String,
    val content: String,
    val updatedAt: Long
)

@Dao
interface PostDao {
    @Query("SELECT * FROM posts ORDER BY updatedAt DESC")
    fun getPosts(): Flow<List<PostEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPosts(posts: List<PostEntity>)

    @Query("SELECT * FROM posts WHERE id NOT IN (:syncedIds)")
    suspend fun getUnsyncedPosts(syncedIds: List<String>): List<PostEntity>

    @Query("UPDATE posts SET synced = 1 WHERE id IN (:ids)")
    suspend fun markAsSynced(ids: List<String>)

    @Query("DELETE FROM posts")
    suspend fun clearPosts()
}

From 2025 onward, using Room's AutoMigration feature is recommended to avoid manual SQL migration scripts, improving development efficiency.

2. Background Task Management: WorkManager

WorkManager schedules background synchronization when the network returns, avoiding execution at inconvenient times. It queues API sync requests and runs them under suitable network conditions.

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

    override suspend fun doWork(): Result {
        val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            "app-db"
        ).build()
        val unsyncedPosts = db.postDao().getUnsyncedPosts(emptyList())
        return try {
            val apiService = ApiService.getInstance()
            apiService.uploadPosts(unsyncedPosts)
            db.postDao().markAsSynced(unsyncedPosts.map { it.id })
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        } finally {
            db.close()
        }
    }
}

// Schedule sync when network is connected
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .build()
val request = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(constraints)
    .build()
WorkManager.getInstance(context).enqueue(request)

Only time‑sensitive operations such as payment or OTP should use ExpeditedWork; regular sync tasks should use normal WorkRequests to conserve battery.

3. Data Paging: Paging 3

Paging 3 loads data in chunks, preventing performance degradation when handling large datasets. It provides smooth, seamless loading similar to video buffering.

class PostRepository(
    private val api: ApiService,
    private val dao: PostDao,
    private val keysDao: PostRemoteKeysDao
) {
    fun getPosts(): Pager<Int, PostEntity> {
        return Pager(
            config = PagingConfig(pageSize = 20, enablePlaceholders = false),
            remoteMediator = PostRemoteMediator(api, dao, keysDao),
            pagingSourceFactory = { dao.getPosts() }
        )
    }
}

@OptIn(ExperimentalPagingApi::class)
class PostRemoteMediator(
    private val api: ApiService,
    private val dao: PostDao,
    private val keysDao: PostRemoteKeysDao
) : RemoteMediator<Int, PostEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PostEntity>
    ): MediatorResult {
        return try {
            val page = when (loadType) {
                LoadType.REFRESH -> 1
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull()
                        ?: return MediatorResult.Success(endOfPaginationReached = true)
                    keysDao.remoteKeys(lastItem.id)?.nextKey
                        ?: return MediatorResult.Success(endOfPaginationReached = true)
                }
            }

            val posts = api.fetchPosts(page = page)
            val endOfPagination = posts.isEmpty()

            dao.db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    keysDao.clearRemoteKeys()
                    dao.clearPosts()
                }
                val keys = posts.map {
                    PostRemoteKeys(
                        postId = it.id,
                        prevKey = if (page == 1) null else page - 1,
                        nextKey = if (endOfPagination) null else page + 1,
                        updatedAt = System.currentTimeMillis()
                    )
                }
                keysDao.insertAll(keys)
                dao.insertPosts(posts.map { it.toEntity() })
            }

            MediatorResult.Success(endOfPaginationReached = endOfPagination)
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

@Entity(tableName = "post_remote_keys")
data class PostRemoteKeys(
    @PrimaryKey val postId: String,
    val prevKey: Int?,
    val nextKey: Int?,
    val updatedAt: Long
)

@Dao
interface PostRemoteKeysDao {
    @Query("SELECT * FROM post_remote_keys WHERE postId = :postId")
    suspend fun remoteKeys(postId: String): PostRemoteKeys?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(keys: List<PostRemoteKeys>)

    @Query("DELETE FROM post_remote_keys")
    suspend fun clearRemoteKeys()
}

RemoteKeys record pagination state to prevent duplicate loads or lost positions, ensuring accurate and continuous paging.

4. Non‑relational Data Storage: DataStore

DataStore is suited for small key‑value data such as tokens or feature flags, offering coroutine‑based Flow APIs and avoiding ANR issues compared with SharedPreferences.

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_preferences")
val USER_TOKEN = stringPreferencesKey("user_token")

suspend fun saveUserToken(context: Context, token: String) {
    context.dataStore.edit { preferences ->
        preferences[USER_TOKEN] = token
    }
}

val userTokenFlow: Flow<String?> = context.dataStore.data
    .map { preferences -> preferences[USER_TOKEN] }

5. UI Immediate Response: Jetpack Compose

When Room data changes, Paging 3 notifies the UI, and Jetpack Compose automatically recomposes the screen, displaying new posts without manual refresh.

@Composable
fun PostListScreen(viewModel: PostListViewModel) {
    val pagingItems = viewModel.pager.collectAsLazyPagingItems()
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
    ) {
        itemsIndexed(pagingItems) { index, post ->
            post?.let {
                PostItem(post = it, index = index)
                if (index < pagingItems.itemCount - 1) {
                    Divider(modifier = Modifier.padding(vertical = 4.dp))
                }
            } ?: run {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(40.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text(text = "Loading...")
                }
            }
        }

        pagingItems.apply {
            when (loadState.refresh) {
                is LoadState.Loading -> item {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(50.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(text = "Refreshing data...")
                    }
                }
                is LoadState.Error -> item {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(50.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(text = "Error refreshing data")
                    }
                }
                else -> {}
            }

            when (loadState.append) {
                is LoadState.Loading -> item {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(50.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(text = "Loading more posts...")
                    }
                }
                is LoadState.Error -> item {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(50.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(text = "Error loading more posts")
                    }
                }
                else -> {}
            }
        }
    }
}

@Composable
fun PostItem(post: PostEntity, index: Int) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp)
    ) {
        Text(
            text = "Post ${index + 1}: ${post.title}",
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.Bold
        )
        Spacer(modifier = Modifier.height(4.dp))
        Text(text = post.content, style = MaterialTheme.typography.bodyMedium)
        Spacer(modifier = Modifier.height(4.dp))
        Text(
            text = "Updated at: ${post.updatedAt}",
            style = MaterialTheme.typography.bodySmall,
            color = Color.Gray
        )
    }
}

6. Overall Architecture Integration

Combining these technologies—Room for relational data, WorkManager for background sync, Paging 3 for efficient loading, DataStore for key‑value storage, and Jetpack Compose for reactive UI—creates a complete offline‑first stack that keeps the app functional and performant in poor network conditions and seamlessly syncs data when connectivity returns.

AndroidJetpack ComposeWorkManagerRoomOffline FirstPaging 3
AndroidPub
Written by

AndroidPub

Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.