Paging 3 with Room: keeping a growing transaction list smooth on Android in 2026
How to page a Room-backed transaction list with Jetpack Paging 3 so years of data stay smooth in Compose — no RemoteMediator, no network, just a local database done right.
A budgeting app’s transaction list looks fine in a demo with thirty rows. It stops looking fine two years in, when a real user has four thousand of them and scrolling the history screen starts to stutter. The fix isn’t a bigger LIMIT, and it isn’t “just add a loading spinner at the bottom” — it’s Jetpack Paging 3, wired directly into Room with no network layer at all. Here’s how I built it for Granyn, and the two mistakes that cost me a day each before I got it right.
Why LazyColumn alone isn’t the problem
The naive version works like every list in every tutorial: one Room query, SELECT * FROM transactions ORDER BY date DESC, mapped to a Flow<List<Transaction>>, collected into a LazyColumn. Compose’s lazy layout is genuinely lazy about composition and rendering — it doesn’t recompose off-screen rows. So the stutter isn’t a Compose problem. It’s upstream: Room has to materialize every one of those four thousand rows into objects, on every emission, every time a single transaction changes anywhere in the table. Add one expense and the whole list rebuilds. That’s the actual cost, and no list-rendering optimization touches it.
What Paging 3 actually buys you here
Paging 3 is usually explained through its network use case — a RemoteMediator fetching pages from an API and caching them locally. That’s not what this is. For a local-first app, the entire pipeline lives in Room: no mediator, no network state, no retry logic. Room generates a PagingSource for you from a @Query that returns PagingSource<Int, Transaction> instead of Flow<List<Transaction>>:
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY date DESC")
fun pagingSource(): PagingSource<Int, Transaction>
}
That one type change is most of the integration. Room already knows how to invalidate a PagingSource when the underlying table changes — the same observation mechanism that powers Flow queries — so pages refresh automatically on writes without you wiring up anything extra.
Building the stream
The PagingSource gets wrapped in a Pager, which owns the paging config — how many rows per page, how far ahead to prefetch:
class TransactionRepository(private val dao: TransactionDao) {
fun pagedTransactions(): Flow<PagingData<Transaction>> =
Pager(
config = PagingConfig(
pageSize = 40,
prefetchDistance = 20,
enablePlaceholders = true,
),
pagingSourceFactory = { dao.pagingSource() },
).flow
}
enablePlaceholders = true is worth calling out: because Room can run SELECT COUNT(*) cheaply on a local table, Paging 3 can size a scrollbar accurately and show placeholder rows for content it hasn’t loaded yet — a fast scroll to the bottom of a four-thousand-row history doesn’t need to load four thousand rows first. That’s a luxury network-backed paging doesn’t get, and it’s the main reason local paging feels instant in a way infinite-scroll feeds usually don’t.
The Compose side
collectAsLazyPagingItems() turns the Flow<PagingData<Transaction>> into something a LazyColumn can index directly:
@Composable
fun TransactionList(viewModel: TransactionViewModel) {
val transactions = viewModel.pagedTransactions.collectAsLazyPagingItems()
LazyColumn {
items(
count = transactions.itemCount,
key = transactions.itemKey { it.id },
) { index ->
val transaction = transactions[index]
if (transaction != null) {
TransactionRow(transaction)
} else {
TransactionRowPlaceholder()
}
}
}
}
itemKey matters more here than in a normal list: without a stable key, Compose keys rows by list position, and inserting a new transaction at the top — which happens constantly in a budgeting app, since new entries sort to the top by date — shifts every row’s identity and triggers a wave of unnecessary recomposition.
The mistake that cost me a day: filtering
The history screen also supports filtering by category. My first instinct was to keep one Pager alive and filter the PagingData stream downstream with .filter { } on each PagingData emission. That’s wrong, and it’s a common enough trap that it’s worth naming directly: filtering after the fact still pages through the unfiltered query, so a category with five matches out of four thousand rows can page through most of the table before showing five results, and the placeholder count is wrong because it reflects the full table’s row count, not the filtered one.
The fix is to make the filter part of the query, not a post-processing step, and rebuild the Pager when the filter changes:
@Query("""
SELECT * FROM transactions
WHERE (:categoryId IS NULL OR category_id = :categoryId)
ORDER BY date DESC
""")
fun pagingSource(categoryId: Long?): PagingSource<Int, Transaction>
val pagedTransactions: Flow<PagingData<Transaction>> = filterState
.flatMapLatest { categoryId -> repository.pagedTransactions(categoryId) }
.cachedIn(viewModelScope)
flatMapLatest cancels the old Pager and starts a fresh one scoped to the new filter, so placeholders, counts, and prefetching are all correct for the query the user is actually looking at. cachedIn(viewModelScope) is what survives configuration changes — without it, rotating the screen restarts paging from scratch.
The takeaway
Paging 3 over Room needs none of the network machinery it’s usually taught with — no RemoteMediator, no LoadState retry UI, no offline cache to reconcile. What’s left is genuinely simple: a PagingSource from a query, a Pager with prefetch config, and collectAsLazyPagingItems() on the Compose side. The one thing worth getting right up front is keeping filters inside the query instead of downstream of it — everything else follows from that. A transaction list that stays responsive at four thousand rows isn’t a performance feature you bolt on later; it’s the difference between an app that works in the demo and one that still works two years into real use.
// Related reading
More from the journal
Room TypeConverters in 2026: storing enums, dates, and lists without corrupting your schema
A practical guide to Room TypeConverters on Android — enums, Instant/LocalDate, and lists — plus the mistakes that turn a converter into a silent data-corruption bug.
R8 and ProGuard for Kotlin, Room, and Compose: the crash that only happens in release
Why a Room + Compose Android app can work perfectly in debug and crash in production, and the specific R8/ProGuard keep rules that catch it before a user does.
Room database indices in 2026: finding the query that's actually slow and fixing it
A practical guide to indexing a Room/SQLite database on Android — reading EXPLAIN QUERY PLAN, adding @Index without guessing, and the mistakes that silently defeat one.