A lightweight, position-based paging library for Kotlin Multiplatform β with intelligent preloading, memory-bounded caching, offline-first sync, and real-time pagination over Server-Sent Events (SSE) / WebSockets. One paging engine for Android, iOS, JVM, macOS, Linux, Windows, JS, and WebAssembly.
Build infinite-scroll lists, virtualized tables, and live-updating feeds with Jetpack Compose / Compose Multiplatform β or any Flow-based UI β from a single shared codebase.
Looking for a multiplatform alternative to Jetpack Paging 3 that also runs on iOS and the web, supports jump-to-position random access, and can stream live updates? That's what this library is for. See Comparison with Jetpack Paging 3.
- π Truly multiplatform β the same paging logic runs on every Kotlin target. No Android dependencies in the core.
- π Position-based (indexed) paging β items are addressed by absolute integer positions, enabling random access, jump-to-index, sparse loading, and correctly sized scrollbars out of the box.
- β‘ Intelligent preloading & bounded cache β data is fetched in chunks around the user's position; items far from the
viewport are evicted automatically to keep memory flat on long lists. Chunk boundaries are snapped to a
loadSizegrid, so a position is always requested under the same(offset, limit)pair β cacheable and never overlapping. - π Offline-first β coordinate a local cache with a remote API via
PagingMediator(a multiplatform take onRemoteMediator): serve cache first, fetch missing ranges, reconcile totals. - π‘ Real-time streaming pagination β
StreamingPagerkeeps paginated lists live by streaming the total count and individual page windows over SSE or WebSockets. (Jump to the example β) - π§© Compose-ready β immutable
PersistentMap-backed snapshots and aFlow<PagingData<T>>API that drops straight intoLazyColumn/LazyList, with a stability configuration that makes composables taking aPagingDataskippable and reads of already-loaded rows free. - π§΅ Coroutines-native & lifecycle-aware β non-blocking,
Mutex-guarded, debounced loading; all internal jobs are bound to the collection lifecycle of the returnedFlowand cancel automatically.
| Pager | Use it when | Source |
|---|---|---|
Pager |
You load pages on demand from a single source (REST, DB, file). | (pos, size) -> Flow<DataPortion<T>> |
PagingMediator |
You want offline-first: read from a local cache, fall back to the network for misses. | LocalDataSource + RemoteDataSource |
StreamingPager (experimental) |
Your data is live and pushed from the server (SSE / WebSocket). | readTotal(): Flow<Int> + readPortion(start, size): Flow<Map<Int, T>> |
- Installation
- Quick Start
- Core Concepts
- Compose Integration
- π‘ Real-time Pagination over SSE (StreamingPager)
- Offline-first with PagingMediator
- Data Mapping
- Supported Platforms
- Comparison with Jetpack Paging 3
- Samples
- License
- Contributing
Available on Maven Central.
// build.gradle.kts
dependencies {
implementation("ua.wwind.paging:paging-core:2.4.0")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.1")
}Prerequisites: Kotlin 2.4.10+, kotlinx-collections-immutable 0.5.1+, and the mavenCentral() repository.
data class User(val id: Int, val name: String, val email: String)
val pager = Pager<User>(
loadSize = 20, // items fetched per request
preloadSize = 60, // preload radius around the current position
cacheSize = 100, // cache radius around the current position β must be >= preloadSize
keyDebounceMs = 300, // settle time for scrolling; the first load is never debounced
concurrency = 4, // chunks fetched in parallel per loading pass
readData = { position, loadSize ->
flow {
val users = repository.getUsers(position, loadSize)
emit(
DataPortion(
totalSize = repository.getTotalCount(),
values = users
.mapIndexed { index, user -> (position + index) to user }
.toMap()
.toPersistentMap()
)
)
// You may emit additional portions progressively if your source streams partial results.
}
}
)
// Observe paging data
pager.flow.collect { pagingData ->
when (pagingData.loadState) {
LoadState.Loading -> showLoader()
LoadState.Success -> hideLoader()
is LoadState.Error -> pagingData.retry(pagingData.loadState.key)
}
// Access items by absolute position; accessing an item triggers loading if needed
when (val firstUser = pagingData.data[0]) {
EntryState.Loading -> showItemLoader()
is EntryState.Success -> displayUser(firstUser.value)
}
}loadState is Loading only while the item at the accessed position is missing and being fetched, and Error when
that fetch fails; scrolling over cached data never changes it. The rest of the preload window is reported separately in
pagingData.prefetchState, and its failures do not affect loadState.
Every item is addressed by an absolute, zero-based integer position in the underlying dataset (for a given query/filter). This is what enables random access, jump-to-index, and accurate scrollbars.
- Use absolute positions as map keys in
DataPortion.values. - If your backend returns only offset + a list, generate keys client-side as
startPosition + indexInPortion. - Persist items in your local cache under the same absolute positions so
Pager,PagingMediator, andStreamingPagercan merge and window data reliably.
| Type | Purpose |
|---|---|
PagingData<T> |
Immutable snapshot: the data window, loadState (the accessed position), prefetchState (background fill of the rest of the window), and a retry(key) callback. |
PagingMap<T> |
Sparse position -> item map. Reading a position notifies the pager (onGet) and may trigger a load. Knows the total size. |
LoadState |
Used for both loadState and prefetchState: Loading Β· Success Β· Error(throwable, key). key is the current accessed position. |
EntryState<T> |
Per-item state: Loading or Success(value). Use getOrNull() for a quick value-or-null read. |
DataPortion<T> |
The contract returned by your data source: totalSize + a PersistentMap<Int, T> of loaded values. |
PagingData is a complete snapshot of the list, so only the newest one is ever worth rendering. Both pagers therefore
expose a conflated flow: a collector that falls behind β a slow frame, or a live stream pushing faster than the UI
draws β is handed the current state instead of a backlog of snapshots it would render and immediately discard. No data
is lost this way, since every snapshot carries the whole window; only intermediate loadState / prefetchState transitions can be
skipped.
Both bypass the key debounce and reload immediately:
pagingData.retry(key)β re-runs the load that failed. Passing the key the error reported (retry(loadState.key)orretry(prefetchState.key)) is the intended call; the ranges that are still missing around it are fetched again, and everything already cached is kept.pager.refresh()β drops the cache and reloads the window around the position last accessed, cancelling the in-flight load first so it cannot write stale items back. Use it when the underlying dataset changed as a whole (a filter changed, a pull-to-refresh, a sign-in).
Position changes are debounced by keyDebounceMs (300 ms by default) so that fast scrolling does not issue a request
per row. Two cases skip it: the initial load, which has no scroll to settle, and explicit retry / refresh
calls. Set keyDebounceMs = 0 if your source is local and cheap enough to serve every position change.
A jump lands in the middle of an empty window, which is tiled into several chunks. Pager keeps up to concurrency
of them in flight (4 by default), so filling the window costs ceil(chunks / concurrency) round trips rather than one
per chunk β with a 100 ms backend, a Β±60 item window around the jump target arrives in 200 ms instead of 600 ms. The
chunk holding the accessed position is always requested first, then the side the position is moving towards, then the
side it is moving away from; each side nearest-first. Set concurrency = 1 for a source that must not be hit in
parallel. PagingMediator passes its own concurrency down to the pager and caps both levels with a single budget,
so it stays the number of requests your remote source can see at once.
@Composable
fun UserList() {
val pagingData by pager.flow.collectAsState(initial = PagingData.empty())
LazyColumn {
items(count = pagingData.data.size) { index ->
when (val entry = pagingData.data[index]) { // accessing an item drives preloading
EntryState.Loading -> LoadingItem()
is EntryState.Success -> UserItem(entry.value)
}
}
}
when (pagingData.loadState) {
LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> ErrorMessage(pagingData.loadState.throwable) {
pagingData.retry(pagingData.loadState.key)
}
LoadState.Success -> Unit
}
}loadState and prefetchState mean the same as in the Quick Start.
paging-core is built without the Compose compiler plugin, which is what keeps it usable from plain Kotlin and free
of a Compose runtime dependency. The flip side is that the Compose compiler cannot infer stability for types it did not
compile: left alone, it treats PagingData and everything around it as unstable, so a composable taking one is never
skippable and re-runs on every emission β even when nothing it reads changed.
The repository ships compose_compiler_config.conf with the types declared stable.
Copy it into your project and point the Compose compiler at it from every module that consumes a pager:
composeCompiler {
stabilityConfigurationFiles.add(
rootProject.layout.projectDirectory.file("compose_compiler_config.conf"),
)
}The claim is accurate: every one of those types is an immutable snapshot of vals, and the pagers publish a new
instance per state change instead of mutating the one already handed out. Your own item type still has to be stable in
its own right for a row to be skippable, which it is by default for a data class of vals compiled in your module.
To check what the compiler makes of it, composeCompiler { reportsDestination.set(...) } writes a report listing every
composable and the stability of each of its parameters. paging-samples wires this up behind a flag:
./gradlew :paging-samples:compileKotlinJvm -PcomposeReportspagingData.data[index] is what tells the pager where the viewport is, and a LazyColumn re-reads every visible row on
every recomposition. Once the window around the reader is loaded, none of those reads can change what the pager would
do, so they no longer touch the key trigger at all: a read is a map lookup and nothing else. Reads within one loadSize
of the edge of the loaded window still count, which is what keeps the window moving ahead of the scroll, and anything
that invalidates the cache β a refresh(), a failed load, a hole left by a short portion β makes every read count
again.
Without a key, a LazyColumn identifies rows by position, so a row that moves (an insert, a delete, a streamed update)
loses its state and animation. Pass itemKey to Pager, StreamingPager or PagingMediatorConfig and key the list
by it:
val pager = Pager<User>(itemKey = { it.id }) { pos, loadSize -> api.users(pos, loadSize) }
val data = pagingData.data
LazyColumn {
items(count = data.size, key = { data.keyAt(it) }) { index ->
// ...
}
}- Compute the key with
data.keyAt(index), never fromdata[index]: reading an item drives preloading, which keys must not do. - Rows that are not loaded yet get a stable fallback key (
PagingMap.FALLBACK_KEY_PREFIX+ index). - The key type must be storable in a
Bundle(Uuid,Stringor a primitive; not a boxed value class), asLazyColumnsaves it. AStringkey must not start withPagingMap.FALLBACK_KEY_PREFIX. - Use one pager per list: keys are unique within a pager, not across pagers.
- A key that briefly appears twice while pages shift (a transient duplicate) is hidden: the copy from older data shows as not yet loaded until the data settles. A key that really appears twice in the source is shown as two rows, each with its own unique key.
- A
readPortionsource that replays its last value to a new subscriber (for exampleshareIn(replay = 1)) can make a stale duplicate count as real when a window reopens, until fresh data arrives.
The standout feature. Most paging libraries assume a request/response data source where each page is fetched once.
StreamingPageris built for live data: it keeps a long-lived stream open for every on-screen page (a portion). When anything inside a page changes on the server, the server pushes the changed rows of that portion over SSE (or WebSocket), and the pager merges them into the list in place β other rows stay untouched, and you never poll or refresh manually. A portion (page ofloadSizeitems) is the unit of a subscription; an update can be as small as a single row.
StreamingPager splits a live source into two independent streams:
readTotal(): Flow<Int>β a continuous stream of the global item count. Drives list size and pruning, and lets the list grow/shrink in real time.readPortion(start, size): Flow<Map<Int, T>>β a long-lived stream for a single page-sized window (no totals). Emissions are keyed by absolute position and merged into the cache: positions an emission carries are overwritten, positions it leaves out keep their cached values. So after a first emission with the whole portion, the source may emit only the rows that changed β no need to resend the page.- Emit the whole portion first on every subscription: a window that closed and reopened may have been pruned.
- An emission cannot remove a row. To delete one, shrink
readTotaland re-emit the rows that shifted.
It then:
- Opens chunk-aligned portion streams inside an active window around the last accessed position, preloading
preloadSizein both directions. - Closes streams gracefully once the viewport moves farther than
closeThresholdaway β so you only hold open subscriptions for what's on screen. - Keeps a bounded cache window (
cacheSize) and prunes out-of-bounds items. - Reports
loadStatefor the range holding the accessed position and aggregates the other windows intoprefetchState(priority:Loading > Error > Success); it survivesreadTotalfailures, recovering onretry.
preloadSizeandcacheSizeare radii, not totals. Both are measured in indices from the last accessed position, so a cache of100holds roughly200items.cacheSizemust be>= preloadSizeβ otherwise the pager would stream a window it cannot retain and throw most of it away on arrival; such a configuration is rejected at construction time. ForStreamingPager, whose chunk grid is aligned toloadSizeand therefore reaches slightly past the preload radius,cacheSize >= preloadSize + loadSizeretains that overshoot too.Pageraligns its chunks to the same grid, but widens its cache window to whatever the current load fetches, so it needs no extra margin.
Perfect for live dashboards, trading/price tables, chat & activity feeds, collaborative lists, and order/inventory boards β anywhere the data changes while the user is looking at it.
@Serializable
data class User(val id: Int, val name: String, val email: String)
// Ktor HttpClient with SSE support
val client = HttpClient(CIO) {
install(SSE)
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
// Stream 1: live total count (server emits an integer in each event's `data:`)
fun totalCountFlow(): Flow<Int> = flow {
client.sse(method = HttpMethod.Get, urlString = "https://api.example.com/users/total/stream") {
incoming.collect { event ->
val value = event.data?.trim()?.toIntOrNull() ?: return@collect
emit(value)
}
}
}
// Stream 2: one live page. The server (re)emits the WHOLE portion for this range
// as a JSON array whenever any item inside it changes.
fun userPortionFlow(position: Int, size: Int): Flow<Map<Int, User>> = flow {
val url = "https://api.example.com/users/portion?start=$position&size=$size"
client.sse(method = HttpMethod.Get, urlString = url) {
incoming.collect { event ->
val users: List<User> = Json.decodeFromString(event.data ?: return@collect)
// Map to absolute positions: position..position + size - 1
emit(users.mapIndexed { idx, user -> (position + idx) to user }.toMap())
}
}
}
@OptIn(ExperimentalStreamingPagerApi::class)
val pager = StreamingPager<User>(
config = StreamingPagerConfig(
loadSize = 20,
preloadSize = 60,
cacheSize = 100,
closeThreshold = 20,
keyDebounceMs = 300,
),
readTotal = { totalCountFlow() },
readPortion = { position, size -> userPortionFlow(position, size) },
)
// Consume exactly like Pager β the list now updates itself in real time
pager.flow.collect { pagingData ->
when (pagingData.loadState) {
LoadState.Loading -> showLoader()
LoadState.Success -> hideLoader()
is LoadState.Error -> pagingData.retry(pagingData.loadState.key)
}
when (val firstUser = pagingData.data[0]) {
EntryState.Loading -> showItemLoader()
is EntryState.Success -> displayUser(firstUser.value)
}
}loadState reports only the accessed position; background prefetch of the rest of the window is in pagingData.prefetchState.
The same model works with WebSockets or any other push transport β just back
readTotal/readPortionwith the flow of your choice. Positions must be absolute across the dataset; when the total shrinks, out-of-bounds streams are cancelled and cached values pruned automatically, and a stream the new end only clipped is reopened for the part of it that survives.
A complete, editable live-list demo ships in the paging-samples module (StreamingUserListScreen).
PagingMediator<T, Q> coordinates a local cache with a remote source while preserving positional paging. It
serves local data first, then fetches the missing ranges β a multiplatform analogue to Paging 3's RemoteMediator, with
per-query flows.
class UserLocalDataSource(private val dao: UserDao) : LocalDataSource<User, Unit> {
override suspend fun read(startPosition: Int, size: Int, query: Unit): DataPortion<User> =
dao.readPortion(startPosition, size)
override suspend fun save(portion: DataPortion<User>) = dao.upsertPortion(portion)
override suspend fun clear() = dao.clearAll()
}
class UserRemoteDataSource(private val api: UserApi) : RemoteDataSource<User, Unit> {
override suspend fun fetch(startPosition: Int, size: Int, query: Unit): DataPortion<User> =
api.fetchUsers(startPosition, size)
}
val mediator = PagingMediator(
local = UserLocalDataSource(dao),
remote = UserRemoteDataSource(api),
config = PagingMediatorConfig(
loadSize = 20, // items per page
prefetchSize = 60, // prefetch radius around the current position
cacheSize = 100, // cache radius β must be >= prefetchSize
concurrency = 2, // concurrent remote fetches, across the whole pager
isRecordStale = { false }, // decide if a cached record must be refreshed
fetchFullRangeOnMiss = false, // refetch the full window on miss/inconsistency
emitOutdatedRecords = false, // emit stale records while refreshing
emitIntermediateResults = true, // emit partial results as ranges arrive
),
)
// Each query owns its own paging flow; pass Unit if you don't filter
mediator.flow(Unit).collect { pagingData ->
// Same UI handling as Pager / StreamingPager
}Key behaviors: emits cached records first (optionally including stale ones), then remote updates; detects inconsistent total sizes and refetches/clears the window when needed; supports configurable parallel fetches and intermediate emissions.
Transform items while preserving loading state and retry logic:
val mapped: PagingData<String> = pagingData.map { user -> "${user.id}: ${user.name}" }
// Only currently-loaded items are transformed; loadState, prefetchState and retry are preserved.| Platform | Targets |
|---|---|
| Android | API 21+ |
| JVM | Java 17+ |
| iOS | iosX64, iosArm64, iosSimulatorArm64 |
| macOS | macosArm64 |
| Linux | linuxX64, linuxArm64 |
| Windows | mingwX64 |
| Web | JavaScript (Node), WebAssembly (WasmJs) |
| Paging KMP | Jetpack Paging 3 | |
|---|---|---|
| Platforms | Android, iOS, JVM, macOS, Linux, Windows, JS, Wasm | Primarily Android/JVM (multiplatform support is limited) |
| Paging model | Position/index-based (random access, jump-to-index) | Cursor/key-based (sequential) |
| Live/streaming updates | β
Built-in via StreamingPager (SSE / WebSocket) |
β Not designed for it |
| Offline-first | β
PagingMediator |
β
RemoteMediator |
| UI | Compose Multiplatform & any Flow consumer | Compose / RecyclerView (Android) |
| Dependencies | Coroutines + immutable collections (no Android in core) | AndroidX |
Choose Paging KMP when you target more than Android, need positional/random access, or want real-time paginated lists. Choose Paging 3 if you're Android-only and happy with cursor-based paging.
The paging-samples module contains complete, runnable Compose Multiplatform examples:
- Basic on-demand paging (
Pager) - Offline-first local + remote sync (
PagingMediator) - Live streaming list with editing (
StreamingPager) - Error handling, retry, and scrollbar integration
Licensed under the Apache License 2.0. See LICENSE for details.
PRs and discussions are welcome! Please add an example to paging-samples for new features.
Code style is enforced by the build, not by review:
./gradlew spotlessApply # format all Kotlin sources β run before committing
./gradlew spotlessCheck detekt # verify formatting and run static analysisSpotless (ktlint) owns formatting, detekt owns code smells; the two rule sets do not overlap. ktlint settings live in
the root build.gradle.kts, deliberate deviations from detekt's default rules live in
config/detekt/detekt.yml, and .editorconfig holds the settings your
editor reads. Both checks are part of ./gradlew check and run as the Static analysis job in CI.
The repository was reformatted in a single commit. To keep git blame readable, run once:
git config blame.ignoreRevsFile .git-blame-ignore-revsKeywords: Kotlin Multiplatform paging, Compose Multiplatform pagination, KMP infinite scroll, Jetpack Paging 3 alternative, iOS Kotlin paging, SSE pagination, WebSocket pagination, real-time paginated list, offline-first paging, position-based paging.