Skip to content
Go back

Fuel Strategy: Paging in Compose Multiplatform

by KMP Bits

KMP Bits Cover

A GT car doesn’t roll out of the garage carrying enough fuel for the full two hours. It would be heavy, slow, and dangerous. So the team runs stints: enough fuel for a window, then back to the box for more. The driver never notices the tank filling because it happens while everything else is going on.

That’s paging. You don’t pull four thousand rows into memory because the user might scroll that far. You pull thirty, and you pull the next thirty while they’re still reading the first ones.

I’d been doing this on Android for years with Paging 3 and never thought about it. Then I moved a list screen into commonMain and stopped short. My PagingSource was in the Android module. My collectAsLazyPagingItems call was in an Android-only artifact. I assumed I’d be writing a hand-rolled offset loader for the shared module and keeping Paging on Android only.

I was working from stale information. Paging has been publishing Kotlin Multiplatform artifacts since 3.3, and paging-compose came along with it. The PagingSource, the Pager, the LazyPagingItems collector: all of it compiles in commonMain now. I confirmed this directly on 3.4.0: androidx.paging:paging-common and androidx.paging:paging-compose resolve as-is for Android and iOS (device and simulator), no Cash App app.cash.paging wrapper needed. 3.3.0 shipped as a stable release before 3.4.0, which lines up with the “since 3.3” claim, though I haven’t personally pinned down the exact patch where KMP targets landed.


What you actually need

Two dependencies in commonMain, and that’s the whole setup.

# libs.versions.toml

[versions]
paging = "3.4.0"

[libraries]
paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
// build.gradle.kts

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.paging.common)
            implementation(libs.paging.compose)
        }
    }
}

No platform source sets. No expect/actual. That surprised me more than it should have, given that the interesting parts of Paging are coroutines and Flow, both of which have been multiplatform since forever. The Android-specific pieces were the RecyclerView adapters, and if you’re on Compose you were never using those.


The PagingSource

PagingSource is where you say how a page is fetched. Two type parameters: the key type, which is whatever your API uses to ask for the next page, and the value type, which is what comes back.

For a page-number API, the source only needs a plain suspend function that returns a list — no wrapper response type required:

// commonMain

interface ArticleApi {
    suspend fun getArticles(page: Int, size: Int): List<Article>
}

class ArticlePagingSource(
    private val api: ArticleApi,
) : PagingSource<Int, Article>() {

    override suspend fun load(
        params: LoadParams<Int>,
    ): LoadResult<Int, Article> {
        val page = params.key ?: 1
        return try {
            val response = api.getArticles(page = page, size = params.loadSize)
            LoadResult.Page(
                data = response,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.isEmpty()) null else page + 1,
            )
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        return state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
        }
    }
}

Two things in there are easy to get wrong.

nextKey = null is how you say the race is over. Return a real key and Paging will keep asking. I’ve seen a list loop forever on the last page because someone returned page + 1 unconditionally and the backend kept happily returning an empty array.

The CancellationException rethrow matters more in KMP than people expect. If you swallow it into LoadResult.Error, a scrolled-past-and-cancelled load turns into a visible error state in the UI. Catch it first, rethrow it, then catch everything else.

getRefreshKey is what Paging calls on a refresh to work out where to restart so the user doesn’t lose their scroll position. Getting it slightly wrong is a mild annoyance, not a crash, which is why it stays wrong in a lot of codebases.


Wiring the Pager

The Pager turns a source into a Flow<PagingData<T>>. This lives in the shared ViewModel:

// commonMain

class ArticleListViewModel(
    private val api: ArticleApi,
) : ViewModel() {

    val articles: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(
            pageSize = 20,
            prefetchDistance = 5,
            enablePlaceholders = false,
            initialLoadSize = 20,
        ),
        pagingSourceFactory = { ArticlePagingSource(api) },
    ).flow.cachedIn(viewModelScope)
}

cachedIn is not optional. Without it, every recomposition that resubscribes to the flow starts a fresh load from page one, and on a configuration change the user watches their list reset. It also makes the flow safe to collect more than once. Add it and move on.

prefetchDistance is the fuel window. Five means Paging starts fetching the next page when the user is five items from the end of what’s loaded. Too low and they hit the bottom and wait. Too high and you’re burning bandwidth on pages nobody reaches. I start at a quarter of the page size and adjust after watching real scrolling.

enablePlaceholders = false is my default. Placeholders need a known total count from the source, which most REST endpoints won’t give you, and null items in the list mean every composable has to handle a null. If your backend returns a total and you want the scrollbar to be honest about list length, turn it on. Otherwise leave it off.

initialLoadSize is the one I’d skip too if I hadn’t been burned by it. It defaults to pageSize * 3 — 60 in this example, not 20 — and with a page-number-keyed source that default is a silent bug. The first load fetches page 1 with size = 60, gets 60 items back, and computes nextKey = 2 because the response wasn’t empty. The next append then asks for page 2 at size = 20, which is items 21–40 — items already covered by that oversized first load. No crash, no error state, just duplicate rows quietly folded into the list the first time the user scrolls. Pin it to pageSize unless you deliberately want a bigger first fetch and have adjusted your key math to match.


The list

This is the part I expected to be Android-only, and isn’t.

// commonMain

@Composable
fun ArticleList(viewModel: ArticleListViewModel) {
    val articles = viewModel.articles.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = articles.itemCount,
            key = articles.itemKey { it.id },
            contentType = articles.itemContentType { "article" },
        ) { index ->
            val article = articles[index]
            if (article != null) {
                ArticleRow(article)
            }
        }
    }
}

articles[index] is what tells Paging the user reached that position, which is what triggers the next load. There’s no explicit “load more” call and no scroll listener. Reading the item is the signal.

The key is worth the extra line. Without a stable key, an item inserted at the top on refresh shifts every row and Compose recomposes the entire visible list. With it, only the new row is composed.


Load states

LazyPagingItems carries a loadState with three fields: refresh, append, and prepend. Each is Loading, NotLoading, or Error. Most of the UI work is mapping those three onto something the user understands.

// commonMain

LazyColumn {
    when (val refresh = articles.loadState.refresh) {
        is LoadState.Loading -> item { FullScreenSpinner() }
        is LoadState.Error -> item {
            ErrorPanel(
                message = refresh.error.message,
                onRetry = { articles.retry() },
            )
        }
        else -> Unit
    }

    items(
        count = articles.itemCount,
        key = articles.itemKey { it.id },
    ) { index ->
        articles[index]?.let { ArticleRow(it) }
    }

    when (val append = articles.loadState.append) {
        is LoadState.Loading -> item { RowSpinner() }
        is LoadState.Error -> item {
            RetryRow(onRetry = { articles.retry() })
        }
        else -> Unit
    }
}

The distinction that matters: a failed refresh should take over the screen, because there’s nothing to show. A failed append should be a small retry row at the bottom, because the user still has thirty perfectly good items above it. Blanking a populated list because page four timed out is the single most common Paging mistake I see, and it’s a one-line fix.

That “nothing to show” caveat matters, though. refresh isn’t only the cold-start load — pull-to-refresh, or any explicit articles.refresh(), sends loadState.refresh through Loading and possibly Error again on a screen that’s already full of items. If FullScreenSpinner() is genuinely full-screen (Modifier.fillMaxSize()) and you render it unconditionally for any Loading/Error on refresh, it’ll cover the list the user was just reading the moment they pull to refresh — the exact mistake this section just warned about, just triggered from the other direction. Gate the full-screen treatment on there being nothing loaded yet (articles.itemCount == 0); once there’s a list on screen, a refresh error is a small banner or retry affordance, same as append.

Empty state needs care too. An empty list is only genuinely empty once the refresh has finished:

val isEmpty = articles.itemCount == 0 &&
    articles.loadState.refresh is LoadState.NotLoading &&
    articles.loadState.append.endOfPaginationReached

Check itemCount == 0 on its own and you’ll flash “No articles found” for a moment on every cold start.


Gotchas I ran into

PagingData doesn’t fit in a StateFlow. It’s a stream of change events, not a value, so wrapping it in a StateFlow or a data class UiState(val items: PagingData<T>) breaks it in ways that don’t show up until something scrolls. Keep it as a Flow<PagingData<T>> and expose the rest of your screen state separately. It’s the one place where the “one state object per screen” rule doesn’t apply.

Filtering and mapping happen on PagingData, not on the list. You can’t filter a LazyPagingItems. Do it upstream on the flow with map and filter from the Paging operators, and remember to apply them before cachedIn so the cache holds the transformed data.

Pager(config, pagingSourceFactory = { source })
    .flow
    .map { pagingData -> pagingData.filter { it.isPublished } }
    .cachedIn(viewModelScope)

Refresh means articles.refresh(), not recreating the Pager. If a filter changes and you rebuild the Pager object, you throw away the cache and any in-flight loads. For a data change, call refresh(). Rebuild the Pager only when the underlying query genuinely changes identity.

Offline caching is where the multiplatform story gets thinner. RemoteMediator is in paging-common and compiles fine in commonMain, but the useful version of it needs a local database that can hand you a PagingSource. On Android that’s Room’s generated one. In KMP you’re either on Room’s KMP support or writing the PagingSource over SQLDelight yourself, which is a real piece of work and not a two-line adapter. I didn’t build this part for the demo, so take it as a heads-up rather than a verified recipe.

Testing pays for itself here. A PagingSource is a plain suspend function with a plain return value, so you can call load() directly in commonTest with a fake API and assert on the keys. That catches the never-ending-list bug in a second, without a device or a scroll.

@Test
fun lastPageReturnsNullNextKey() = runTest {
    // 40 articles at pageSize 20 means page 3 is the one that comes back empty —
    // asserting against page 2 here would still be a full page and this would fail.
    val source = ArticlePagingSource(FakeApi(totalArticles = 40))
    val result = source.load(
        LoadParams.Refresh(key = 3, loadSize = 20, placeholdersEnabled = false),
    ) as LoadResult.Page
    assertNull(result.nextKey)
}

One dependency I glossed over above: runTest comes from kotlinx-coroutines-test, not from paging-common or paging-compose. Add it to commonTest.dependencies for this. If you’d rather not pull in the extra artifact, a plain PagingSource test like this one doesn’t actually need it — kotlinx.coroutines.runBlocking is already transitive through paging-common and works fine here, since nothing in this test touches Dispatchers.Main. You only need kotlinx-coroutines-test once you’re testing something that runs on viewModelScope (which does dispatch through Dispatchers.Main) — then runTest plus Dispatchers.setMain(UnconfinedTestDispatcher()) earns its keep.


Wrapping up

What I keep coming back to is how little of this is platform code. The source, the pager, the composable, the load state handling, the tests: all of it sits in commonMain and runs on both platforms without a single expect declaration. I’d budgeted a week for writing my own pagination layer for the shared module. It took an afternoon, and most of that was reading release notes to convince myself the artifacts really were multiplatform.

The parts worth being careful about aren’t multiplatform problems at all. They’re the same things that bite on Android: return null for nextKey when you’re done, call cachedIn, pin initialLoadSize to pageSize if your key math assumes uniform pages, and don’t let a failed append — or a failed refresh, once there’s already a list on screen — wipe out content the user is already reading.

Run it in stints. Load a window, top up before the user reaches the bottom, and they never see the pit stop happening.

Keep it flat out. 🏁


The full demo for this article is available on GitHub.

The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Change the Map: Feature Flags and Remote Config in Kotlin Multiplatform