Implementing pagination in RecyclerView from API
RecyclerView renders only the rows attached to its window, so a large API response does not need to become one giant in-memory list. Loading every record increases memory use, delays the first screen, and behaves poorly when a mobile connection changes. Requesting another batch near the end of the current results keeps startup fast and controlled.
Android Paging 3 coordinates those requests with RecyclerView while retaining page keys, cancellation, retry behaviour, and refresh semantics. This example uses Kotlin, Retrofit, and a numbered REST endpoint. A cursor-based service needs a different key type, but most of the UI code can stay unchanged.
Define the response contract
Design the endpoint around explicit paging fields, such as GET /articles?page=1&page_size=20. A minimal Kotlin-friendly response could be:
{"items":[{"id":41,"title":"Pagination example","body":"API content"}],"page":1,"last_page":5}
Check whether the service supplies last_page, has_more, or a next cursor. Keep the server limit no higher than the largest page RecyclerView needs, validate that pages increase, and define an empty final page. If the API returns a bare array, add an adapter around it rather than scattering offset calculations throughout the app.
For an Australian events app distributed through Google Play, seed records around Sydney, Melbourne, and Brisbane, display prices in Australian dollars, label GST-inclusive tickets clearly, and retain each venue’s IANA time zone so daylight-saving changes do not shift its start time. Location-aware discovery can build on fused location updates before requesting the first page, but denied location permission should fall back to a national feed rather than block loading.
Build Retrofit and PagingSource
Add the runtime, Retrofit, and converter dependencies in the app module. Pin versions consistently with the rest of the project:
implementation("androidx.paging:paging-runtime-ktx:3.3.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
Model the response and expose the Retrofit call. The domain key will be an Int page number, while each row keeps the stable ID supplied by the server.
data class ArticlePage(
val items: List<Article>,
val page: Int,
@SerializedName("last_page") val lastPage: Int
)
data class Article(
val id: Int,
val title: String,
val body: String
)
interface ArticleApi {
@GET("articles")
suspend fun getArticles(
@Query("page") page: Int,
@Query("page_size") pageSize: Int
): ArticlePage
}
PagingSource translates Retrofit’s page number into Paging 3 keys. It returns the previous page for backward restoration, advances only when another page exists, and converts network or HTTP failures into retryable errors.
class ArticlePagingSource(
private val api: ArticleApi
) : PagingSource<Int, Article>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Article> {
val page = params.key ?: 1
return try {
val result = api.getArticles(page, params.loadSize)
LoadResult.Page(
data = result.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (
result.items.isEmpty() || page >= result.lastPage
) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(
state: PagingState<Int, Article>
): Int? {
val anchor = state.anchorPosition ?: return null
val page = state.closestPageToPosition(anchor) ?: return null
return page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
}
}
For page-number APIs, getRefreshKey selects the page around the visible anchor after invalidation. For an opaque cursor, use String keys and request the cursor returned by the previous page. Never derive the next request from an adapter position, because layout changes and headers can make positions unstable.
Cache pages in a ViewModel
Keep Retrofit creation separate from Android UI objects and let the repository own the Pager. A 20-item page with a five-item prefetch gives Paging room to fetch before the user reaches the boundary.
class ArticleRepository(private val api: ArticleApi) {
fun articles(): Flow<PagingData<Article>> = Pager(
PagingConfig(
pageSize = 20,
initialLoadSize = 20,
prefetchDistance = 5,
enablePlaceholders = false
),
pagingSourceFactory = { ArticlePagingSource(api) }
).flow
}
Map API objects into UI-specific models inside the ViewModel so transport details stay out of adapters and layouts.
data class ArticleUiModel(
val id: Int,
val heading: String,
val summary: String
)
class ArticleViewModel(
private val repository: ArticleRepository
) : ViewModel() {
val articles = repository.articles()
.map { data ->
data.map { item ->
ArticleUiModel(item.id, item.title, item.body)
}
}
.cachedIn(viewModelScope)
}
Because cachedIn shares one cached PagingData stream while the ViewModel survives configuration changes, collectors can stop safely with the screen and resume without creating duplicate generations. Supply the repository through the app’s factory or dependency-injection graph.
Render rows and load states
Use a fixed-height or constrained row layout, set a LinearLayoutManager on the RecyclerView, and keep pagination independent of row height. For very long articles, calculate a practical maximum based on screen space rather than sending every field to bind.
Implement PagingDataAdapter so DiffUtil compares stable IDs and changed content. A nullable item is normal while a page is loading.
private object ArticleDiff :
DiffUtil.ItemCallback<ArticleUiModel>() {
override fun areItemsTheSame(
oldItem: ArticleUiModel,
newItem: ArticleUiModel
) = oldItem.id == newItem.id
override fun areContentsTheSame(
oldItem: ArticleUiModel,
newItem: ArticleUiModel
) = oldItem == newItem
}
class ArticleAdapter :
PagingDataAdapter<ArticleUiModel, ArticleViewHolder>(ArticleDiff) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = ArticleViewHolder(
ItemArticleBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(
holder: ArticleViewHolder,
position: Int
) {
holder.bind(getItem(position))
}
}
Create the ViewModel with the application configuration, attach the adapter, and collect only while the Activity is started. Calling submitData from the UI layer lets Paging cancel an obsolete generation.
private val adapter = ArticleAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.articles.layoutManager = LinearLayoutManager(this)
binding.articles.adapter = adapter.withLoadStateHeaderAndFooter(
header = ArticleLoadStateAdapter(adapter::retry),
footer = ArticleLoadStateAdapter(adapter::retry)
)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.articles.collectLatest {
adapter.submitData(it)
}
}
}
}
The header and footer should render Loading, Error with a Retry action, and an end message only when endOfReachedReached is true. Keep an append failure in the footer while existing rows remain visible; reserve a full-screen error for a failed refresh with no items. A small state row is enough and avoids covering useful content.
Control refresh and compare strategies
Wire SwipeRefreshLayout to adapter.refresh() and call adapter.retry() from the error controls. Observe loadStateFlow so the refresh spinner follows the library’s state instead of a separate Boolean. Preserve visible rows when an append fails, prevent duplicate submissions, and use stable IDs where the backend guarantees uniqueness. For Australian Android users moving between Melbourne mobile broadband and a train connection, a retry should restore only the missing page.
The right approach depends on what the API can guarantee and whether random page jumps are required.
| Approach | Server contract | Main benefit | Main limitation |
|---|---|---|---|
| Numbered pages | page, page_size, total or has_more |
Supports refresh and possible random jumps | Added or removed records can shift pages between requests |
| Cursor or keyset | Opaque next cursor | Stable ordering for frequently changing feeds | Random jumps are unavailable; cursor rules belong in the source |
| Manual RecyclerView state | Offset, loading flag, optional total | Works for a small legacy endpoint | Duplicates, race conditions, and lost scroll position become app responsibilities |
A conventional page-number endpoint fits this implementation well: PagingSource owns key arithmetic, the ViewModel caches generations, and the adapter only renders PagingData and load states. That separation keeps scrolling smooth after rotation and makes later endpoint changes local to the data layer.