A Practical Guide to Paging 3 in Jetpack Compose
How To Elegantly Page Your Server Data on Your Android App
Learning Android Development
A Practical Guide to Paging 3 in Jetpack Compose
How To Elegantly Page Your Server Data on Your Android App
Photo by Mateo Krossler on Unsplash
You’ve just wired up a LazyColumn that fetches data from an API. You test it on your Pixel 8 Pro and it’s buttery smooth. Nice work. Then a colleague opens the same screen on a mid-range device with a list of 10,000 items.
The app pauses. The spinner spins. Spins some more. Then one of three things happens:
- UI heroically renders all 10,000 rows at once and the phone turns into a hand warmer
- An
**OutOfMemoryError**closes the app with all the grace of a nightclub bouncer, or © it somehow works but the user has already left a one-star review.
Pagination is the obvious answer — but rolling your own is a trap. You end up badly reimplementing load state, retry logic, scroll-triggered fetching, and empty-state handling. That’s precisely what **Paging 3** solves, and it does it elegantly.
In this article we’ll walk through a demo app that paginates a GraphQL API using Paging 3 and Jetpack Compose. No prior pagination experience required — just bring your Kotlin and a rough idea of how LazyColumn works.
Think of It Like a Sushi Conveyor Belt
Close your eyes. You’re at a conveyor-belt sushi restaurant. (Actually keep them open — you need to read this.)
The chef doesn’t dump all 300 pieces of sushi onto the belt at once. Your table would be buried, the fish would go cold, and the bill would be terrifying. Instead, the chef watches from behind the counter: when the belt runs low near your seat, a fresh tray appears. You always have a few pieces in front of you. You’re never overwhelmed, and you’re never waiting with empty chopsticks.
That’s Paging 3 in a nutshell: load data in small chunks, on demand, just ahead of where the user is scrolling. Items you’ve scrolled past can be released from memory. Items just around the corner are quietly prefetched.
Here are the key players, mapped to the analogy:
- PagingSource: The chef — knows how to prepare exactly one tray of items
- Pager: Belt manager — decides when to call the chef
- PagingConfig: The settings knob — tray size, how far ahead to start preparing the next one
- PagingData: The belt itself — a stream of trays flowing toward your seat
- LazyPagingItems: Your chopsticks — the Compose handle for grabbing items off the belt
- LoadState: The status light above the counter — loading, done, or something went wrong

What I like about this abstraction is that neither you nor the UI is ever thinking about pages directly. You’re just eating sushi. The belt keeps moving.
To have a clearer picture, I have coded a simple design here, with an illustration diagram as below.

The Chef’s Secret Recipe: “PagingSource” and GraphQL
The PagingSource is where your code lives. Everything else in Paging 3 is framework machinery — your job is to implement one method: load().
load() receives a LoadParams object with two pieces of information: the key for this page (a cursor, a page number, an offset — whatever your API uses), and the requested load size. You fetch the data and return a LoadResult. That’s genuinely the whole contract.
Look for three things:
params.key(the cursor that identifies this page), the network call, and wherenextKeycomes from — that chain is what keeps the belt moving.
override suspend fun load(params: LoadParams<String>): LoadResult<String, Node> {
val cursor = params.key // null on the very first load
return try {
val response = apolloClient.query(
GetFunFactsQuery(first = params.loadSize,
after = if (cursor != null) Optional.present(cursor) else Optional.absent())
).execute()
val connection = response.data?.funFacts
?: return LoadResult.Error(Exception("No data"))
LoadResult.Page(
data = connection.edges.map { it.node },
prevKey = null,
nextKey = if (connection.pageInfo.hasNextPage) connection.pageInfo.endCursor else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
The magic is in nextKey. Return a cursor and Paging 3 passes it back as params.key on the next call. Return null and the library knows the belt has run out of sushi — end of list. That single return value drives the entire “load more” chain, automatically.
This demo uses GraphQL cursor-based pagination via Apollo Kotlin, talking to a local Spring Boot server. The concept is identical for REST APIs — swap the cursor String for a page Int, replace the Apollo call with a Retrofit call, and you’re done. The Paging 3 machinery doesn’t care.
Now let’s look at how the PagingSource gets wired up. The Pager in the repository is where PagingConfig lives:
pageSizeandprefetchDistanceare the main dials. HereprefetchDistance = 1means “start fetching the next page when the user is 1 item from the bottom” — a tight but visible trigger, great for learning.
Pager(
config = PagingConfig(
pageSize = 10,
initialLoadSize = 10, // default is pageSize × 3; override for clean Logcat output
enablePlaceholders = false,
prefetchDistance = 1 // start fetching when 1 item from the end
),
pagingSourceFactory = { FunFactsPagingSource(apolloClient, onPageLoaded) }
).flow
The pagingSourceFactory lambda is called every time the list is invalidated (think: pull-to-refresh). A fresh PagingSource instance gets a clean slate — fresh cursor, fresh page counter.
One more method worth mentioning: getRefreshKey() on your PagingSource. It tells Paging 3 where to restart after an invalidation. Returning null means “start from the top”, which is the safest default for cursor-based APIs because cursors are position-specific — you can’t jump to the middle of a cursor chain.
Chopsticks Out: LazyColumn Meets LoadState
On the UI side, the story is refreshingly short. You need three things: collect the data stream, display it in a LazyColumn, and react to load states. Paging 3 handles all three without a single isLoading: Boolean variable in your ViewModel.
The critical line is
cachedIn(viewModelScope)— without it, the list reloads from scratch on every recomposition. One line of insurance against a very annoying bug.
// ViewModel — expose a cached Flow<PagingData<T>>
val funFacts: Flow<PagingData<Node>> =
repository.getFunFactsPager().cachedIn(viewModelScope)
// Composable — collect it as LazyPagingItems
val funFacts = viewModel.funFacts.collectAsLazyPagingItems()
when (val state = funFacts.loadState.refresh) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> ErrorView(state.error, onRetry = { funFacts.retry() })
is LoadState.NotLoading -> {
if (funFacts.itemCount == 0) EmptyView()
else FunFactsList(funFacts)
}
}
loadState exposes three slots: refresh (the initial load or a pull-to-refresh), prepend (loading above the current position), and append (loading more at the bottom).
In this demo, append drives a footer spinner — a small CircularProgressIndicator that appears at the end of the `LazyColumn``while the next page loads, then disappears silently.
The retry() call is one of my favourite Paging 3 features. One line, and the library reruns exactly the failed load() call — no “which page was I on?” bookkeeping, no manual state to reset. You just point users to a Retry button, call funFacts.retry(), and walk away.
For a learning twist, the demo logs each page load to Logcat and fires a Snackbar notification via a Channel<String> in the ViewModel. It’s a deliberate visibility trick: pagination usually happens silently, but seeing “Fetched page 2–10 items” pop up as you scroll makes the otherwise invisible machinery tangible. Highly recommend keeping something like this around while you’re learning.
TL;DR: Let the Chef Handle It
The conveyor belt doesn’t care how many pieces of sushi are in the kitchen. It just keeps the belt stocked, one tray at a time, as fast as you can eat. Paging 3 works exactly the same way — it doesn’t care how many items your API can return. It loads a page, watches your scroll position, and prepares the next one just in time.
The setup cost — PagingSource, Pager, PagingConfig, cachedIn — is a one-time investment. After that, scroll performance, retry logic, empty states, and memory management are handled for you.
Opinionated take: if your list has more than ~50 items coming from a network source, you probably want Paging 3. The setup cost pays for itself on the first scroll, and it only gets cheaper as your data grows.
Grab the full demo repo and run it — the Logcat output and Snackbars make the pagination surprisingly tangible to watch. When you’re ready to go deeper — offline caching with Room, or RemoteMediator for combining local and remote sources — official Paging 3 docs are your next stop.
Happy scrolling. 🍣
메타데이터
- post_id
- e88f56c5b2df
- slug
- a-practical-guide-to-paging-3-in-jetpack-compose-e88f56c5b2df
- url
- https://medium.com/mobile-app-development-publication/a-practical-guide-to-paging-3-in-jetpack-compose-e88f56c5b2df
- canonical_url
- https://medium.com/mobile-app-development-publication/a-practical-guide-to-paging-3-in-jetpack-compose-e88f56c5b2df
- author_url
- https://medium.com/@elye-project
- status
- ok
- fetched_at
- 2026-06-15 20:49:13