Paging 3 & Offline-First Architecture — Loading Data the Right Way
Building modern Android apps requires handling massive datasets efficiently while maintaining a smooth offline experience. In this guide, we'll explore how to combine Paging 3 with an offline-first architecture to create responsive, resilient applications.
Part 1: Paging 3 Fundamentals
PagingSource Implementation
PagingSource is the core abstraction that defines how to load data from your data source. Whether fetching from an API or database, you implement the load() function:
class ApiPagingSource(private val api: MyApi) : PagingSource<Int, Item>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
return try {
val page = params.key ?: 1
val response = api.getItems(page = page, pageSize = params.loadSize)
LoadResult.Page(
data = response.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.items.isEmpty()) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
return state.anchorPosition?.let { position ->
state.closestPageToPosition(position)?.nextKey?.minus(1)
}
}
}
Pager Configuration
Set up a Pager to manage pagination logic and expose paginated data as a Flow:
class MyRepository(private val api: MyApi) {
fun getItems(): Flow<PagingData<Item>> {
return Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = true,
maxSize = 100,
initialLoadSize = 40,
prefetchDistance = 5
),
pagingSourceFactory = { ApiPagingSource(api) }
).flow
}
}
Key config parameters:
- pageSize: Items per page
- initialLoadSize: First page size (often 2x pageSize)
- prefetchDistance: Trigger next load when user scrolls within this distance
- maxSize: Keep max items in memory
collectAsLazyPagingItems in Compose
In Compose, collect paginated data as a lazy list state:
@Composable
fun ItemListScreen(viewModel: MyViewModel) {
val lazyPagingItems = viewModel.items.collectAsLazyPagingItems()
LazyColumn {
items(
count = lazyPagingItems.itemCount,
key = { index -> lazyPagingItems[index]?.id ?: index },
contentType = { "item" }
) { index ->
val item = lazyPagingItems[index]
if (item != null) {
ItemRow(item)
} else {
// Placeholder while loading
ShimmerPlaceholder()
}
}
// Load more indicator at the end
when (lazyPagingItems.loadState.append) {
is LoadState.Loading -> {
item { LoadingIndicator() }
}
is LoadState.Error -> {
item { ErrorMessage("Failed to load more") }
}
is LoadState.NotLoading -> {}
}
}
}
LoadState Handling
Monitor loading, error, and completion states across the pagination lifecycle:
@Composable
fun PaginationStateUI(lazyPagingItems: LazyPagingItems<Item>) {
// Initial load state
when (lazyPagingItems.loadState.refresh) {
is LoadState.Loading -> {
Box(Modifier.fillMaxSize()) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
is LoadState.Error -> {
val error = (lazyPagingItems.loadState.refresh as LoadState.Error).error
ErrorScreen(error = error)
}
is LoadState.NotLoading -> {}
}
// Append (load more) state
LaunchedEffect(lazyPagingItems.loadState.append) {
if (lazyPagingItems.loadState.append is LoadState.Error) {
val error = (lazyPagingItems.loadState.append as LoadState.Error).error
showRetrySnackbar(error)
}
}
}
Room PagingSource
For database-backed pagination, Room provides seamless integration:
@Dao
interface ItemDao {
@Query("SELECT * FROM items ORDER BY id DESC")
fun pagingSource(): PagingSource<Int, Item>
}
class DbPagingSource(private val dao: ItemDao) : PagingSource<Int, Item>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
return try {
val page = params.key ?: 0
val items = dao.getItemsPaged(
limit = params.loadSize,
offset = page * params.loadSize
)
LoadResult.Page(
data = items,
prevKey = if (page == 0) null else page - 1,
nextKey = if (items.size < params.loadSize) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Item>): Int? = null
}
Part 2: Offline-First Architecture
Room as Single Source of Truth
Implement the single source of truth pattern where Room database is the primary data store:
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: Int,
val title: String,
val description: String,
@ColumnInfo(name = "sync_status") val syncStatus: String = "synced"
)
@Dao
interface ItemDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(item: ItemEntity)
@Update
suspend fun update(item: ItemEntity)
@Query("SELECT * FROM items WHERE sync_status = :status")
suspend fun getItemsBySync(status: String): List<ItemEntity>
}
Repository with Sync Flag
Expose data from Room while tracking sync state:
class ItemRepository(
private val api: MyApi,
private val dao: ItemDao,
private val networkMonitor: NetworkMonitor
) {
fun getItems(): Flow<PagingData<Item>> {
return Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { dao.pagingSource() }
).flow
.map { pagingData ->
pagingData.map { it.toDomain() }
}
}
suspend fun syncItems() {
if (!networkMonitor.isOnline()) return
try {
val response = api.getItems()
val entities = response.map { it.toEntity() }
// Update local DB
entities.forEach { dao.insert(it.copy(syncStatus = "synced")) }
} catch (e: Exception) {
// Mark as pending sync on error
dao.markAllAsPending()
}
}
suspend fun markForSync(itemId: Int) {
dao.update(dao.getItem(itemId).copy(syncStatus = "pending"))
}
}
WorkManager Periodic Sync
Schedule background sync to keep offline data fresh:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
return@withContext try {
val repository = // inject repository
repository.syncItems()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// Schedule periodic sync
fun setupPeriodicSync(context: Context) {
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"item_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
NetworkMonitor with ConnectivityManager
Detect network availability to drive offline-first behavior:
interface NetworkMonitor {
fun isOnline(): Boolean
val isOnlineFlow: Flow<Boolean>
}
class ConnectivityNetworkMonitor(context: Context) : NetworkMonitor {
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val _isOnlineFlow = MutableStateFlow(false)
override val isOnlineFlow = _isOnlineFlow.asStateFlow()
init {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isOnlineFlow.value = true
}
override fun onLost(network: Network) {
_isOnlineFlow.value = false
}
}
connectivityManager.registerNetworkCallback(
NetworkRequest.Builder().build(),
networkCallback
)
}
override fun isOnline(): Boolean {
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
return caps?.hasCapability(NET_CAPABILITY_INTERNET) == true
}
}
Bringing It Together
Combine Paging 3 with offline-first in your ViewModel:
class ItemViewModel(
private val repository: ItemRepository,
private val networkMonitor: NetworkMonitor
) : ViewModel() {
val items: Flow<PagingData<Item>> = repository.getItems()
val isOnline = networkMonitor.isOnlineFlow
init {
viewModelScope.launch {
networkMonitor.isOnlineFlow.collect { isOnline ->
if (isOnline) {
repository.syncItems()
}
}
}
}
}
This architecture ensures users always see data (offline), while seamlessly syncing when possible. Paging 3 efficiently manages memory, and WorkManager keeps data fresh in the background.
8 Android App Templates → https://myougatheax.gumroad.com
Top comments (0)