DEV Community

Cover image for The Compose Search Bug That Debounce Doesn't Fix
Prem Singh
Prem Singh

Posted on Fully Autonomous

The Compose Search Bug That Debounce Doesn't Fix

A user searches for cat, then changes the query to car. For a moment, results for cats appear under the new query.

You already added debounce(300) and flatMapLatest. So why can the old request still finish?

The answer is operator placement. Debouncing reduces request frequency. It does not automatically cancel an in-flight search the instant the user edits the text.

The familiar pipeline has a timing window

Consider this simplified chain:

queries
    .debounce(300)
    .flatMapLatest { query -> searchFlow(query) }
Enter fullscreen mode Exit fullscreen mode

After the user types car, the new value waits inside debounce. Until that value reaches flatMapLatest, the previous inner flow can continue. If the cat response arrives during that gap, it can still be emitted.

The Kotlin debounce reference describes suppression of rapidly followed values. The flatMapLatest reference describes cancellation when a new upstream value reaches that operator. Those are different moments.

This behavior can be acceptable when the UI deliberately keeps old results visible and labels them correctly. If you want a new query to cancel old work promptly, move the waiting period inside the switched flow.

Switch first, wait second

The following integration sketch accepts a Flow of query strings and a cancellable suspend search function. It separates waiting, loading, empty results, and failure so the UI can communicate each state clearly.

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map

data class SearchHit(val id: String, val title: String)

sealed interface SearchState {
    data object Idle : SearchState
    data class Waiting(val query: String) : SearchState
    data class Loading(val query: String) : SearchState
    data class Results(
        val query: String,
        val items: List<SearchHit>,
    ) : SearchState
    data class Failed(val query: String) : SearchState
}

@OptIn(ExperimentalCoroutinesApi::class)
fun searchStates(
    queries: Flow<String>,
    search: suspend (String) -> List<SearchHit>,
): Flow<SearchState> = queries
    .map { it.trim() }
    .distinctUntilChanged()
    .flatMapLatest { query ->
        flow<SearchState> {
            if (query.length < 2) {
                emit(SearchState.Idle)
            } else {
                emit(SearchState.Waiting(query))
                delay(300)
                emit(SearchState.Loading(query))
                try {
                    emit(SearchState.Results(query, search(query)))
                } catch (cancelled: CancellationException) {
                    throw cancelled
                } catch (_: Exception) {
                    emit(SearchState.Failed(query))
                }
            }
        }
    }
    .buffer(0)
Enter fullscreen mode Exit fullscreen mode

This is a Flow-layer example, not a complete Compose app. Connect your repository and UI using dependencies compatible with your project. The experimental opt-in reflects the linked API reference; check the status for your coroutines version.

Now a new distinct, trimmed query reaches flatMapLatest before the delay. It cancels the previous inner flow, including its waiting period or cooperative in-flight request. The new flow then starts its own waiting period.

buffer(0) avoids an extra queued output buffer after the switching operator. It cannot retract a value the UI has already received, so keep the query attached to every result and render it honestly.

Keep text input responsive

Debounce the search operation, not the text field's displayed value. Typing and cursor movement should update immediately.

For state-based Compose text fields, TextFieldState owns editable text, selection, and composition. You can observe its text with snapshotFlow and feed that into the search pipeline. Preserve the text field state rather than replacing it with a fresh instance on every recomposition. The Android TextFieldState reference includes examples.

Choose the lifetime of the collector deliberately. For screen state on Android, lifecycle-aware collection is generally appropriate; a search operation should not accidentally gain a lifetime longer than its owner.

Three details worth getting right

A blank query must reach the switch. Filtering out short queries before flatMapLatest can leave an older search active when the user clears the field. This example emits Idle inside the switched flow instead.

Catch errors per request. The failure handler sits inside the inner flow. A failed request becomes a state while the outer query stream remains available for later input. A single catch after the whole pipeline can instead complete that collection after an error.

Cancellation is cooperative. A suspend function is not automatically well behaved. The repository must support cancellation and avoid blocking the calling thread. Cancelling a client request also doesn't guarantee the server stopped processing it. Never swallow CancellationException as an ordinary search error.

What about retrying the same query?

distinctUntilChanged intentionally suppresses equal adjacent queries. Assigning the same string again won't implement retry.

Model retry as a separate action. For example, include an attempt counter in a request model and increment it on retry, so the request stream sees a new value even when the query is unchanged. Decide whether explicit retries should bypass the typing delay.

Test the timing, not just the final list

Use a fake search implementation and virtual time to verify these scenarios:

Scenario Expected behavior
Rapid typing Only the settled query starts a search
Edit during a request Previous cooperative request is cancelled when the new query reaches the switch
Clear the field State becomes Idle; previous work is cancelled
Request fails, then query changes A new search can still run
Retry unchanged text Explicit retry creates another attempt

A small demo showing the query, current state, and cancellation logs makes this a useful portfolio feature. It demonstrates an engineering decision that a polished screenshot alone cannot show.

The next time you review a search pipeline, ask: does cancellation happen when the user changes intent, or only after the debounce window?


Prem Singh · Lead Android Engineer · Kotlin, Jetpack Compose & Android architecture

GitHub · LinkedIn

Top comments (0)