byfedin.
← Back to selected workOFFER CATALOG / OPEN-SOURCE CASE STUDY

Asynchronous state,
handled with care.

An independent Android sample about the bugs that hide in search and pagination: stale responses, lost pages, and state that disappears after process death.

MY ROLE
Sole author
CONTEXT
Independent · MIT licence
FOCUS
Search · pagination · restoration
CORE STACK
Kotlin · Compose · Coroutines
01 / WHY THIS PROJECT

A small app.
Real failure modes.

Commercial code usually can’t be shown, so I built a sample that can. Offer Catalog lets you browse 32 fictional offers, search, filter by category, open details and come back to exactly where you were.

The data is local and fictional, with a fixed 450 ms delay, so the interesting part is the asynchronous behaviour rather than the backend: what happens when you type faster than results arrive, when a page fails halfway down the list, or when Android kills the process in the background.

It needs no account, network or keys. Clone it, run it, and read the tests.

03 / ARCHITECTURE

One module,
clear boundaries.

One Gradle module organised by responsibility. The repository interface is the asynchronous boundary that tests replace.

  • catalog/ state, search, pagination, catalog UI
  • detail/ detail state and UI
  • data/ repository boundary, fixtures, async source
  • model/ offers, categories, filters, page results
  • ui/ theme, status UI, local illustrations
04 / DECISIONS WORTH DISCUSSING

Choices, with
their trade-offs.

Each decision is small on purpose and says when it should change.

CatalogViewModel.ktsimplified excerpt
private fun reload(debounce: Boolean) {
    // Invalidate synchronously: an old response must not win during the debounce window.
    generation++
    request?.cancel()
    val token = generation
    val filter = state.value.filter.normalized()
    mutableState.value = state.value.copy(content = CatalogContent.Loading)
    request = viewModelScope.launch {
        if (debounce) delay(debounceMillis)
        load(filter, offset = 0, token = token)
    }
}

private suspend fun load(filter: CatalogFilter, offset: Int, token: Long) {
    val page = repository.page(filter, offset)
    // Cancellation is cooperative. The token also rejects sources that return after cancellation.
    if (token != generation) return
    // ... merge the page, deduplicate by stable ID
}
01 / STALE RESULTS

Invalidate before debounce.

Changing the query increments a generation and cancels the request immediately; only then does a cancellable 300 ms delay debounce typing. A response is applied only if its generation still matches, so even a source that ignores cancellation can’t show results for an old query. Category changes skip the debounce.

Coroutines · Job cancellation · Generation token

02 / PAGINATION

A failed page keeps the list.

Initial loading and errors are separate from results. Results own their continuation offset and append status, so an append error shows a retry under the existing offers instead of wiping them. The busy flag is set before launching, so repeated scroll events can’t request the same page twice; items are deduplicated by stable ID.

Sealed UI state · Explicit append status

03 / PROCESS DEATH

Restore what matters.

The query and category live in SavedStateHandle; the list’s scroll state is owned above NavHost so it survives navigation. After process death the search parameters come back and the first page reloads. Keeping the whole collection would be a different trade-off, and the README says so.

SavedStateHandle · rememberLazyListState · Navigation Compose

04 / SCOPE

No framework for its own sake.

One repository is passed through ViewModel factories: no DI framework, no pass-through use cases, no base classes. A small catalogue-specific paginator replaces Paging 3, which would be the right choice with persistent caching, refresh keys or large datasets.

ViewModel factories · Hand-written paginator

05 / HOW IT’S VERIFIED

Tests that prove
the behaviour.

Unit tests run on coroutine virtual time with controlled completions and no real sleeps.

22unit tests: rapid input, stale success and failure, duplicate scrolls, retry, restoration
6instrumented Compose tests: search → detail → back, scroll retention, failures
kill -9a real process-death check in CI on API 26 and 36, outside the app process
577 msmedian Activity display, debug build on an API 36 emulator: a baseline, not a claim

A deliberately cancellation-resistant fake checks the generation guard. GitHub Actions runs formatting, unit tests, lint, a debug build and instrumented tests, and uploads reports even when they fail.

06 / DELIBERATE LIMITS

What it doesn’t
pretend to be.

KotlinJetpack ComposeCoroutines & FlowStateFlowSavedStateHandleNavigation ComposeGitHub Actions
LET’S TALK ANDROID

Have a product
to build↗