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:

StateWhen
refreshInitial load or pull-to-refresh
appendLoading the next page
prependLoading 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:

  1. Paging reads from Room (fast, offline-capable)
  2. When Room runs out of data, RemoteMediator fetches from the API
  3. RemoteMediator saves API data to Room
  4. 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.



This is part 10 of the Android Development Tutorial series.