Your app has a list of 10,000 items. Loading them all at once is a bad idea. The API call takes forever. The device runs out of memory. The UI freezes while Room queries thousands of rows.
You need pagination — load 20 items at a time, and load more when the user scrolls near the bottom.
You could build this manually. Track the current page. Handle loading and error states. Cache pages. Manage memory. Or you could use Paging 3, which does all of this for you.
Prerequisites: Compose Tutorial #6: Lists, Compose Tutorial #12: Retrofit, and Tutorial #3: Repository Pattern.
Paging 3 Architecture
Paging 3 has three main components:
┌─────────────────┐
│ LazyColumn + │ UI Layer
│ LazyPagingItems │
└────────┬────────┘
│
┌────────▼────────┐
│ Pager + │ ViewModel
│ PagingData │
└────────┬────────┘
│
┌────────▼────────┐
│ PagingSource │ Data Layer (API only)
│ or │
│ RemoteMediator │ Data Layer (API + Room)
└─────────────────┘
- PagingSource — loads data from a single source (API or database)
- RemoteMediator — coordinates between API and Room for offline-first pagination
- Pager — creates a
Flow<PagingData>from the PagingSource - LazyPagingItems — Compose integration for LazyColumn
Setup
# gradle/libs.versions.toml
[versions]
paging = "3.4.1"
pagingCompose = "3.4.1"
[libraries]
paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" }
paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "paging" }
dependencies {
implementation(libs.paging.runtime)
implementation(libs.paging.compose)
testImplementation(libs.paging.testing)
}
Simple PagingSource (API Only)
Start with the simplest case: loading pages from an API.
API Interface
interface UserApi {
@GET("users")
suspend fun getUsers(
@Query("page") page: Int,
@Query("limit") limit: Int = 20
): UserListResponse
}
@Serializable
data class UserListResponse(
val data: List<UserDto>,
val page: Int,
@SerialName("total_pages")
val totalPages: Int
)
PagingSource
class UserPagingSource(
private val api: UserApi
) : PagingSource<Int, User>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
val page = params.key ?: 1
return try {
val response = api.getUsers(page = page, limit = params.loadSize)
val users = response.data.map { it.toDomain() }
LoadResult.Page(
data = users,
prevKey = if (page == 1) null else page - 1,
nextKey = if (page >= response.totalPages) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, User>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
getRefreshKey tells Paging where to start loading when the data is refreshed (pull-to-refresh).
Pager in ViewModel
@HiltViewModel
class UserListViewModel @Inject constructor(
private val api: UserApi
) : ViewModel() {
val users: Flow<PagingData<User>> = Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
prefetchDistance = 5 // Load next page when 5 items from the end
),
pagingSourceFactory = { UserPagingSource(api) }
).flow.cachedIn(viewModelScope)
}
Critical: Always use cachedIn(viewModelScope). Without it, every recomposition creates a new Pager and reloads from page 1. cachedIn caches the paged data across configuration changes.
LazyColumn with LazyPagingItems
@Composable
fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel()
) {
val users = viewModel.users.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
count = users.itemCount,
key = users.itemKey { it.id }
) { index ->
val user = users[index]
if (user != null) {
UserCard(user = user)
}
}
// Loading indicator at the bottom
when (users.loadState.append) {
is LoadState.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is LoadState.Error -> {
item {
RetryButton(
onRetry = { users.retry() }
)
}
}
else -> {}
}
}
}
Loading States
Paging 3 provides three types of loading states:
| State | When |
|---|---|
refresh | Initial load or pull-to-refresh |
append | Loading the next page |
prepend | Loading a previous page (rare) |
Each state can be Loading, NotLoading, or Error.
Full Loading State Handling
@Composable
fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel()
) {
val users = viewModel.users.collectAsLazyPagingItems()
Box(modifier = Modifier.fillMaxSize()) {
when (users.loadState.refresh) {
is LoadState.Loading -> {
// First load — show full screen loading
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is LoadState.Error -> {
// First load failed — show full screen error
val error = (users.loadState.refresh as LoadState.Error).error
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Failed to load: ${error.localizedMessage}")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { users.retry() }) {
Text("Retry")
}
}
}
is LoadState.NotLoading -> {
if (users.itemCount == 0) {
// Empty state
Text(
text = "No users found",
modifier = Modifier.align(Alignment.Center)
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
count = users.itemCount,
key = users.itemKey { it.id }
) { index ->
users[index]?.let { UserCard(user = it) }
}
// Append loading/error
item {
when (users.loadState.append) {
is LoadState.Loading -> LoadingFooter()
is LoadState.Error -> ErrorFooter(onRetry = { users.retry() })
else -> {}
}
}
}
}
}
}
}
}
Pull-to-Refresh
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel()
) {
val users = viewModel.users.collectAsLazyPagingItems()
val isRefreshing = users.loadState.refresh is LoadState.Loading
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { users.refresh() },
state = pullToRefreshState,
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
count = users.itemCount,
key = users.itemKey { it.id }
) { index ->
users[index]?.let { UserCard(user = it) }
}
}
}
}
RemoteMediator — Offline-First Pagination
The real power of Paging 3 is RemoteMediator. It coordinates between the API and a local Room database.
The flow:
- Paging reads from Room (fast, offline-capable)
- When Room runs out of data, RemoteMediator fetches from the API
- RemoteMediator saves API data to Room
- Room PagingSource emits the new data
LazyColumn → Room PagingSource → Room DB ← RemoteMediator ← API
Remote Key Entity
Track which page was last loaded:
@Entity(tableName = "remote_keys")
data class RemoteKeyEntity(
@PrimaryKey val id: String,
val nextPage: Int?,
val prevPage: Int?,
val createdAt: Long = System.currentTimeMillis()
)
@Dao
interface RemoteKeyDao {
@Query("SELECT * FROM remote_keys WHERE id = :id")
suspend fun getById(id: String): RemoteKeyEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(key: RemoteKeyEntity)
@Query("DELETE FROM remote_keys WHERE id = :id")
suspend fun deleteById(id: String)
}
RemoteMediator Implementation
@OptIn(ExperimentalPagingApi::class)
class UserRemoteMediator(
private val api: UserApi,
private val database: AppDatabase
) : RemoteMediator<Int, UserEntity>() {
private val userDao = database.userDao()
private val remoteKeyDao = database.remoteKeyDao()
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, UserEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val remoteKey = remoteKeyDao.getById("users")
remoteKey?.nextPage ?: return MediatorResult.Success(
endOfPaginationReached = true
)
}
}
return try {
val response = api.getUsers(page = page, limit = state.config.pageSize)
val users = response.data.map { it.toEntity() }
val endReached = page >= response.totalPages
database.withTransaction {
if (loadType == LoadType.REFRESH) {
userDao.deleteAll()
remoteKeyDao.deleteById("users")
}
remoteKeyDao.insert(
RemoteKeyEntity(
id = "users",
prevPage = if (page == 1) null else page - 1,
nextPage = if (endReached) null else page + 1
)
)
userDao.insertAll(users)
}
MediatorResult.Success(endOfPaginationReached = endReached)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
Room PagingSource
Room generates PagingSource automatically:
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY id ASC")
fun pagingSource(): PagingSource<Int, UserEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(users: List<UserEntity>)
@Query("DELETE FROM users")
suspend fun deleteAll()
}
Wiring It Together
@HiltViewModel
class UserListViewModel @Inject constructor(
private val api: UserApi,
private val database: AppDatabase
) : ViewModel() {
@OptIn(ExperimentalPagingApi::class)
val users: Flow<PagingData<User>> = Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false
),
remoteMediator = UserRemoteMediator(api, database),
pagingSourceFactory = { database.userDao().pagingSource() }
).flow
.map { pagingData ->
pagingData.map { entity -> entity.toDomain() }
}
.cachedIn(viewModelScope)
}
Now the list works offline. Previously loaded pages are cached in Room. New pages are fetched from the API when needed.
Search with Paging
Invalidate the PagingSource when the search query changes:
@HiltViewModel
class SearchViewModel @Inject constructor(
private val api: UserApi
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val searchResults: Flow<PagingData<User>> = _query
.debounce(300) // Wait 300ms after typing stops
.distinctUntilChanged()
.flatMapLatest { query ->
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = {
if (query.isBlank()) {
UserPagingSource(api)
} else {
SearchPagingSource(api, query)
}
}
).flow
}
.cachedIn(viewModelScope)
fun updateQuery(newQuery: String) {
_query.value = newQuery
}
}
class SearchPagingSource(
private val api: UserApi,
private val query: String
) : PagingSource<Int, User>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
val page = params.key ?: 1
return try {
val response = api.searchUsers(query, page, params.loadSize)
LoadResult.Page(
data = response.data.map { it.toDomain() },
prevKey = if (page == 1) null else page - 1,
nextKey = if (page >= response.totalPages) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, User>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
Search UI
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val query by viewModel.query.collectAsStateWithLifecycle()
val results = viewModel.searchResults.collectAsLazyPagingItems()
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = { viewModel.updateQuery(it) },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
placeholder = { Text("Search users...") },
singleLine = true
)
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
count = results.itemCount,
key = results.itemKey { it.id }
) { index ->
results[index]?.let { UserCard(user = it) }
}
}
}
}
Item Separators and Headers
Add date headers between items:
val users: Flow<PagingData<User>> = pager.flow
.map { pagingData ->
pagingData.insertSeparators { before, after ->
if (before == null || after == null) return@insertSeparators null
val beforeDate = formatDate(before.createdAt)
val afterDate = formatDate(after.createdAt)
if (beforeDate != afterDate) {
afterDate // Return string as separator
} else {
null
}
}
}
.cachedIn(viewModelScope)
To use separators with different types, use a sealed class:
sealed interface UserListItem {
data class UserItem(val user: User) : UserListItem
data class DateHeader(val date: String) : UserListItem
}
KMP Compatibility
Paging 3 now supports Kotlin Multiplatform. The core artifacts (paging-common, paging-compose) work on Android, iOS, and desktop. This means your PagingSource logic can be shared across platforms.
Testing PagingSource
class UserPagingSourceTest {
private lateinit var fakeApi: FakeUserApi
private lateinit var pagingSource: UserPagingSource
@Before
fun setup() {
fakeApi = FakeUserApi()
pagingSource = UserPagingSource(fakeApi)
}
@Test
fun `first page loads correctly`() = runTest {
fakeApi.setUsers(createUsers(20), totalPages = 5)
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(
key = null,
loadSize = 20,
placeholdersEnabled = false
)
)
assertTrue(result is PagingSource.LoadResult.Page)
val page = result as PagingSource.LoadResult.Page
assertEquals(20, page.data.size)
assertNull(page.prevKey)
assertEquals(2, page.nextKey)
}
@Test
fun `last page has null nextKey`() = runTest {
fakeApi.setUsers(createUsers(10), totalPages = 1)
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(
key = 1,
loadSize = 20,
placeholdersEnabled = false
)
)
assertTrue(result is PagingSource.LoadResult.Page)
val page = result as PagingSource.LoadResult.Page
assertNull(page.nextKey)
}
@Test
fun `network error returns LoadResult Error`() = runTest {
fakeApi.shouldThrow = true
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(
key = null,
loadSize = 20,
placeholdersEnabled = false
)
)
assertTrue(result is PagingSource.LoadResult.Error)
}
}
Testing with TestPager
@Test
fun `pages load sequentially`() = runTest {
val pager = TestPager(
config = PagingConfig(pageSize = 20),
pagingSource = UserPagingSource(fakeApi)
)
val firstPage = pager.refresh() as PagingSource.LoadResult.Page
assertEquals(20, firstPage.data.size)
val secondPage = pager.append() as PagingSource.LoadResult.Page
assertEquals(20, secondPage.data.size)
val allItems = pager.getPages().flatMap { it.data }
assertEquals(40, allItems.size)
}
What’s Next?
In this tutorial, you learned:
- Paging 3 architecture: PagingSource, RemoteMediator, Pager
- Simple API-only pagination with PagingSource
- Loading states: refresh, append, prepend
- Pull-to-refresh with paged lists
- Offline-first pagination with RemoteMediator and Room
- Search with paging invalidation
- Item separators and headers
- Testing PagingSource with TestPager
This completes Part 2: Data & Networking. In the next part, we will cover Android platform features: notifications, foreground services, content providers, deep links, and widgets.
Continue with Android Tutorial #11: Notifications when it is available.
Related Articles
- Compose Tutorial #6: Lists — LazyColumn basics
- Compose Tutorial #12: Retrofit — API calls
- Android Tutorial #3: Repository Pattern — data layer
- Android Tutorial #6: Retrofit Advanced — production API layer
- Android Tutorial #7: Room Advanced — database patterns
This is part 10 of the Android Development Tutorial series.