Feed Screens Don't Need a List: Paging Room Data into Compose with Paging 3
A big List kills feed performance. Here's how to stream Room data with Paging 3 and LazyColumn, plus the invalidation and placeholder trade-offs you need to know.
09 Feb 2026, 04:53 UTC

The problem with handing a feed to Compose as a List
You have a feed screen. It queries Room for every row, maps them to a List<Item>, and passes that list to a LazyColumn. It works in the demo, then stalls in production. Memory climbs with the table. The first frame waits for the full query. A refresh or search invalidates more UI than necessary because the list is a snapshot, not a stream.
The fix is to stop modeling the screen as a snapshot. Model it as a stream of pages.
From snapshot to stream: the Paging 3 flow
Jetpack Paging 3 changes the contract. Instead of a DAO returning List<Article>, it returns PagingSource<Int, Article>. The repository wraps that source in a Pager, which emits PagingData<Article> through a Flow. The ViewModel exposes Flow<PagingData<Article>>. Compose collects it into LazyPagingItems.
Why this helps: the database query no longer runs once for the whole table. The PagingSource loads chunks—say 20 rows at a time. Configuration changes and process death re-subscribe to the flow rather than re-querying everything. Scrolling stays bounded because LazyColumn only composes what is visible plus a small prefetch window.
One caution: PagingData is not a random-access list. articles[index] may be null until that page loads. Your UI must handle nulls and loading states.
A worked local-only feed
Assume a local-only article feed backed by Room. You need the Paging 3 artifacts; check the exact androidx.paging versions resolved in your project before copying code.
1. Room DAO returns a PagingSource
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY publishedAt DESC")
fun pagingSource(): PagingSource<Int, Article>
}
Room implements this for you. The Int is the page number or key type; here it maps to the row limit/offset internally.
2. Repository builds a Pager
class ArticleRepository(private val dao: ArticleDao) {
fun feed(): Flow<PagingData<Article>> {
return Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 6,
enablePlaceholders = false
),
pagingSourceFactory = { dao.pagingSource() }
).flow
}
}
pageSize = 20 means each load fetches 20 rows. prefetchDistance = 6 triggers the next load when the user is 6 items from the edge. enablePlaceholders = false keeps null rows out of the UI.
3. ViewModel exposes the flow
class FeedViewModel(repo: ArticleRepository) : ViewModel() {
val articles: Flow<PagingData<Article>> = repo.feed()
.cachedIn(viewModelScope)
}
cachedIn keeps the stream alive across configuration changes so the list does not restart from scratch on rotation.
4. Compose renders LazyPagingItems
@Composable
fun FeedScreen(vm: FeedViewModel) {
val articles = vm.articles.collectAsLazyPagingItems()
LazyColumn {
items(
count = articles.itemCount,
key = articles.itemKey { it.id }
) { index ->
val article = articles[index]
if (article != null) {
ArticleRow(article)
}
}
// Footer for append loading/retry
// ...
}
}
Use a stable key when possible—article.id is safer than a mutable server field. Changing keys resets scroll position. Pull-to-refresh calls articles.refresh(). A failed append shows a retry button that calls articles.retry().
Trade-offs you cannot ignore
Paging adds lifecycle and invalidation complexity. Room invalidates the PagingSource when the table changes. That invalidation can restart the list, which may feel like a jump if the user was deep in the feed.
Placeholders are a UX decision, not a free lunch. With enablePlaceholders = false, you avoid rendering null rows and the UI stays simple, but total-count feedback becomes approximate. With enablePlaceholders = true, you must handle nullable items and stable keys, and uneven page loads can cause flicker.
Adding network with RemoteMediator compounds the cost. You now coordinate remote keys, LoadStates, and refresh/append logic. That coordination is easy to get subtly wrong. Keep the network out of the first iteration if the local database is the source of truth.
How to check it actually works
- Run the feed on an emulator, scroll to the end, and use the CPU/memory profiler. Confirm composed rows stay bounded and the row query is not returning the whole table.
- Add an instrumentation test that scrolls the
LazyColumnuntilloadState.appendisNotLoadingand asserts the item count matches the expected number of pages. - Toggle
enablePlaceholdersandprefetchDistancein a debug build to observe placeholder rendering, prefetch timing, and scroll-position behavior before choosing defaults.
Start with Room and PagingSource. Ship the bounded scroll. Add RemoteMediator only when the network genuinely needs to be in the loop.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.