Migrating Legacy Android Paging 2 to Paging 3
A practical migration guide for Android engineers moving legacy Paging 2 screens to Paging 3 with Room and Kotlin Flow.
Table of Contents5 sections

How do you efficiently load and display large datasets in a mobile application without overwhelming system memory or blocking the main thread? When dealing with thousands of records fetched from a remote API and stored in a local SQLite database, loading everything at once causes catastrophic performance drops, excessive memory consumption, and sluggish user interfaces. Developers frequently encounter this challenge when building data-intensive screens. To solve this, you need a coordinated paging strategy that bridges local persistence with asynchronous streams.
This article is for teams maintaining a Paging 2 codebase. The goal is not to preserve a new Paging 2 architecture, but to map DataSource, PagedList, and BoundaryCallback responsibilities to Paging 3’s PagingSource, PagingData, and RemoteMediator incrementally. Document the migration checkpoints just as carefully as the code changes; the RayLabs guide to technical README instructions explains why explicit module contracts make risky upgrades reviewable.
Understanding the Core Components and Data Flow
Before writing code or configuring Gradle dependencies, it is essential to map out the responsibilities of each architectural component. Paging 2 operates by loading data in chunks on demand, reducing the memory footprint of your lists. Room provides an abstraction layer over SQLite, allowing compile-time verified database queries that can return observable data structures, such as DataSource.Factory objects. Meanwhile, Kotlin Flow introduces asynchronous stream processing that handles emissions gracefully across threads.
The challenge when combining these tools lies in defining the single source of truth. If your user interface observes the local Room database directly, you achieve fast rendering and offline capability. However, you also need a mechanism to fetch fresh data from the network when local records are stale or missing. This is where the NetworkBoundResource pattern becomes invaluable, acting as a traffic controller between your local cache and remote API endpoints.
To visualize this relationship, consider how data flows from storage layers into an active view representation. The illustration below highlights the layered depth of our architectural components.
Designing the NetworkBoundResource Pattern
The NetworkBoundResource pattern solves a fundamental architectural puzzle: how to expose a single stream of data to the UI while transparently orchestrating local database reads and remote network calls. Implementing this pattern requires careful consideration of state transitions, thread dispatchers, and error handling.
A robust implementation typically revolves around a generic class that emits a sealed class representing data states: Loading, Success, and Error. Within this structure, the logic evaluates whether a network fetch is necessary based on the current state of the local database. For instance, if the local database does not have enough items to fulfill the requested page, or if a cache expiration timestamp has passed, the system triggers an API call, stores the resulting payload into the Room database, and lets the updated database rows flow naturally to the UI.
Let us examine a concrete scenario. Suppose you are building a repository for a transaction history list. The user scrolls down, approaching the end of the currently loaded items. Paging 2 requests the next boundary. Your repository checks if Room contains records for that offset. If the local cache is exhausted or outdated, the repository initiates a network request. Once the network response arrives, it executes an atomic database transaction, inserting the new records. Because Room supports observable queries, the UI updates automatically without requiring manual list reassignments.
When designing this flow, you must account for partial network failures. If the network call fails, your application should not wipe out existing local data. Instead, it should emit the cached data alongside an error notification or status flag, allowing the user to continue interacting with offline data while displaying a subtle retry banner.
Configuring Room and Paging 2 Integration
Integrating Room with Paging 2 relies heavily on the DataSource.Factory returned by your DAO queries. Room handles the generation of paged data sources natively, transforming SQL limit and offset clauses into efficient database queries that execute off the main thread.
To set up this integration, your DAO must define queries that return a DataSource.Factory<Int, YourEntity>. When you pass this factory into a LivePagedListBuilder or adapt it for use with Kotlin Flow, the paging library automatically observes database invalidations. Whenever you insert new items fetched from your network source, Room notifies the active data source, and the paging adapter updates the displayed items smoothly.
@Dao
interface ItemDao {
@Query("SELECT * FROM items ORDER BY timestamp DESC")
fun getItems(): DataSource.Factory<Int, ItemEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<ItemEntity>)
@Query("DELETE FROM items")
suspend fun deleteAll()
}
While Paging 2 provides built-in functionality for handling network requests, caching, and error handling through its PageKeyedDataSource or ItemKeyedDataSource, transitioning these older data sources to modern Kotlin Flow requires careful bridge management. Many development teams choose to wrap the paging results or combine them with Flow transformations in their ViewModels to maintain a clean reactive boundary.
When configuring your Gradle dependencies, ensure that your versions for Room, Paging, and Lifecycle are mutually compatible. Version mismatches between database annotation processors and paging adapters can lead to subtle runtime crashes or memory leaks during configuration changes.
Handling State Transitions and Edge Cases
A production-ready application must handle more than just the happy path. You must verify UI behavior for loading, empty, success, and failure states under diverse network conditions. A common pitfall is failing to reset loading indicators when a network pagination request completes or fails.
Consider the following verification checklist for your implementation:
- Test empty data scenarios where both the local database and remote API return zero records, ensuring an appropriate empty state illustration appears.
- Verify duplicate data handling by feeding overlapping page identifiers from the network to ensure Room’s conflict resolution strategy prevents primary key violations.
- Validate database migration paths when updating entity schemas alongside your paging keys.
- Simulate partial-failure scenarios where the network drops mid-pagination, confirming that previously loaded local records remain accessible.
To ensure architectural stability, keep your configuration reproducible and separate machine-specific values from shared project settings. Verify your workflow from a clean build environment, rather than relying solely on an already-configured developer machine where cached artifacts might mask missing dependencies.
Practical Takeaways
Implementing Paging 2 with Room and Kotlin Flow requires deliberate architectural planning. By establishing a clear separation of concerns, your local database remains the single source of truth for rendering, while your network boundary handles data freshness asynchronously.
Always define explicit cache invalidation rules and test your pagination pipelines against empty, duplicate, and failed network states before releasing updates to users. While newer pagination libraries exist, mastering these foundational integration patterns gives you deep control over how data moves through your application stack, ensuring predictable performance and a resilient user experience.
Continue Exploring
You Might Also Like

Mastering List to String Conversion in Mobile Development
An in-depth guide on handling list to string conversion, managing Android lifecycles, and avoiding memory leaks during state transformation.

Android Date and Time: Model Instants, Local Dates, and Time Zones Correctly
Learn how to model and format date and time in Android with Kotlin by separating absolute instants, local calendar values, time zones, localization, and testable presentation logic.

FCM Delivery Monitoring: Know What Sent, Delivered, and Opened Actually Mean
A practical guide to Firebase Cloud Messaging observability that separates send acceptance, aggregated delivery, app processing, and user interaction instead of treating one success response as proof of delivery.