byfedin.
← All notesNOTE · 1 NOV 2026 · 2 MIN READ

A failed page shouldn't wipe the list.

Modelling pagination state so that an append error shows a retry under the existing results, and repeated scroll events can't request the same page twice.

You scroll down a long list, the next page fails to load, and the whole screen turns into a full-page error. Everything you already saw is gone. It is one of the most common pagination bugs, and it usually comes from a single isError flag shared by the first load and every page after it.

Separate the two kinds of failure

In Offer Catalog the screen state keeps them apart:

kotlin
sealed interface CatalogContent {
    data object Loading : CatalogContent
    data object Error : CatalogContent
    data class Results(
        val offers: List<Offer>,
        val nextOffset: Int?,
        val append: AppendState = AppendState.Idle,
    ) : CatalogContent
}

enum class AppendState { Idle, Loading, Error }

Loading and Error only describe the very first page. Once there are results, failures live inside Results as an append status, next to the offers and the cursor that produced them. The UI can then show the list with a small retry row at the bottom, and the cursor is right there for the retry to reuse.

Mark busy before you launch

The second bug hides in scroll handling. A list near its end can fire "load more" several times in a row. If the busy flag is set inside the coroutine, every one of those calls starts a request for the same page. Setting it synchronously, before launching, closes that gap:

kotlin
private fun append(offset: Int, results: CatalogContent.Results) {
    // Mark busy before launching so repeated scroll events cannot enqueue the same page.
    mutableState.value = state.value.copy(content = results.copy(append = AppendState.Loading))
    val token = generation
    val filter = state.value.filter.normalized()
    request = viewModelScope.launch { load(filter, offset, token) }
}

loadMore() simply returns while the status isn't Idle. Appended offers are also deduplicated by stable ID, so a page that overlaps the previous one can't show the same card twice.

The test that pins it down

kotlin
@Test
fun `append failure preserves content and explicit retry uses same cursor`() = runTest {
    val model = model()
    runCurrent()
    source.calls.first().succeed(listOf(offer("one")), 8)
    runCurrent()
    model.loadMore()
    runCurrent()
    source.calls.last().fail()
    runCurrent()
    assertEquals(AppendState.Error, model.results().append)
    assertEquals("one", model.results().offers.single().id)
    repeat(10) { model.loadMore() }
    runCurrent()
    assertEquals(2, source.calls.size)
    repeat(10) { model.retry() }
    runCurrent()
    assertEquals(3, source.calls.size)
    assertEquals(8, source.calls.last().offset)
}

Ten scroll events after a failure start nothing new. Ten taps on retry start exactly one request, with the same cursor. The visible item never disappears.

Why not Paging 3?

For this sample, a hand-written paginator of a few dozen lines is easier to read and test than a PagingSource with its own lifecycle. Paging 3 becomes the better choice once you need a persistent cache, refresh keys, placeholders or very large datasets. The state model above is still worth knowing either way, because Paging's LoadState makes the same split between refresh and append for exactly this reason.

Code and tests are in the repository on GitHub.

LET’S TALK ANDROID

Have a product
to build↗