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

Invalidate before you debounce.

Why a search screen needs a generation token on top of cancellation, and how a cancellation-resistant fake proves it.

Most search screens debounce typing, and most of them cancel the previous request. That sounds complete. It isn't, and the gap shows up exactly when the network is slow and the user types fast.

The bug

Picture the order of events. The user types "coa", a request starts. They keep typing "coast", and the screen waits 300 ms before searching again. If the old request is only cancelled when the new one starts, there is a window of 300 ms where the old response can still land and render results for a query that is no longer on screen.

Cancelling earlier helps, but cancellation in coroutines is cooperative. A source that does its work in a non-cancellable block, or a library that finishes and returns anyway, can still hand back a result after you asked it to stop. If your code applies whatever comes back, stale data wins.

The fix: a generation token

In Offer Catalog the ViewModel bumps a counter the moment the filter changes, cancels the running job, and only then starts the debounce:

kotlin
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)
    }
}

Every response carries the token it was started with. Before touching state, the loader checks it:

kotlin
val page = repository.page(filter, offset)
// Cancellation is cooperative. The token also rejects sources that return after cancellation.
if (token != generation) return

Two details matter. The increment happens synchronously in the event handler, not inside the delayed coroutine, so the old request is invalid from the first keystroke. And category changes skip the debounce entirely, because a tap is a deliberate choice and should apply immediately.

Proving it with a test

A normal fake would respect cancellation, so the guard would never be exercised. The test repository has a switch that makes it ignore cancellation on purpose:

kotlin
return try {
    if (ignoreCancellation) withContext(NonCancellable) { call.response.await() }
    else call.response.await()
} catch (cancelled: CancellationException) {
    call.cancelled = true
    throw cancelled
}

The test then completes the new request first and the old one afterwards, which is the worst ordering a real network can produce:

kotlin
@Test
fun `late success cannot overwrite a newer query`() = runTest {
    source.ignoreCancellation = true
    val model = model()
    runCurrent()
    val old = source.calls.first()
    model.setQuery("new")
    advanceTimeBy(300)
    runCurrent()
    source.calls.last().succeed(listOf(offer("new")), null)
    runCurrent()
    old.succeed(listOf(offer("old")), 8)
    runCurrent()
    assertEquals(listOf("new"), model.results().offers.map { it.id })
}

It runs on virtual time, so there are no sleeps and no flakiness, and a sibling test does the same for a late failure.

When this is overkill

If your data layer is a Room Flow that you flatMapLatest over, the operator already drops the old upstream for you, and a token adds little. The token earns its place when you call suspend functions directly, page manually, or depend on a client you don't fully control. In those cases it costs one Long and one comparison, which is cheap insurance against a bug that is very hard to reproduce by hand.

The full ViewModel and tests are in the repository on GitHub.

LET’S TALK ANDROID

Have a product
to build↗