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.
- π 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. - π§΅ 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.2.7")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
}Prerequisites: Kotlin 2.3.10+, kotlinx-collections-immutable 0.4.0+, 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, // items kept warm around the current position
cacheSize = 100, // max items retained in memory
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)
}
}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, the global loadState, 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 |
Global state: Loading Β· Success Β· Error(throwable, key). |
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. |
@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
}
}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 re-emits that whole portion over SSE (or WebSocket), and the pager swaps that page into the list in place β other pages stay untouched, and you never poll or refresh manually. The unit of a live update is a portion (page ofloadSizeitems), not an individual 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). Each emission carries the current contents of that whole portion (keyed by absolute position); the pager merges it into the cache, so a change anywhere in the range re-pushes and re-renders that entire page.
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. - Aggregates per-window
LoadState(priority:Loading > Error > Success) and survivesreadTotalfailures, recovering onretry.
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)
}
}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.
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, // items preloaded around the current position
cacheSize = 100, // max items kept in memory
concurrency = 2, // concurrent remote fetches
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 and retry are preserved.| Platform | Targets |
|---|---|
| Android | API 21+ |
| JVM | Java 17+ |
| iOS | iosX64, iosArm64, iosSimulatorArm64 |
| macOS | macosX64, 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 keep the code style consistent and add an example to paging-samples for new
features.
Keywords: 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.