androidinterview.com

Kotlin Flow Interview Questions

20 questions

Tier
Difficulty
Level

Showing all 20 questions

Flow Basics & Builders

What is Flow in Kotlin?

Tier: EssentialDifficulty: Easy

Flow is Kotlin's type for an asynchronous stream of values, built on coroutines, that emits zero or more items over time and then completes, with or without an error.

A Flow has three pieces.

  • A builder, usually flow { }, that produces values with emit().
  • Intermediate operators like map or filter that transform the stream without running it.
  • A terminal operator like collect that actually starts the flow and receives the values.
fun getNumbers(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}

// nothing runs until this
getNumbers().collect { println(it) }

That last point is the one people miss. A Flow does nothing on its own, it's a cold, lazy sequence. Calling getNumbers() doesn't touch the network or run the loop, it just builds a description of the work. The block only executes when a collector calls collect, and it runs fresh for every new collector.

Flow exists because coroutines alone only get you a single suspended result. A suspend function returns one value once. Flow is the coroutine equivalent of a stream, useful for things like search results as the user types, rows from a Room query, or progress updates from a download, where you need many values over time instead of one.

Read more Asynchronous Flow (opens in a new tab)

What is the difference between a Cold Flow and a Hot Flow?

Tier: EssentialDifficulty: Medium

A cold flow only runs its producer when a collector subscribes, and it runs that producer fresh, from the start, for every new collector. A hot flow runs independently of whether anyone is collecting, and every active collector shares the same stream of emissions instead of getting its own.

  • Plain Flow, built with flow { }, is cold. Two collectors mean the block inside flow { } runs twice, in full, from the top.
  • StateFlow and SharedFlow are hot. Whoever calls emit or sets .value runs once, and every collector observes the same emissions from whenever it subscribed.
val cold = flow { println("started"); emit(1) }
cold.collect { }  // prints "started"
cold.collect { }  // prints "started" again

val hot = MutableStateFlow(0)
hot.collect { }   // no "started" print, nothing runs on subscribe

The practical reason this matters, a cold flow wrapping a network call means every new collector triggers its own network request, which is exactly what you want for a one shot fetch. A hot flow wrapping something like connectivity state means every screen observing it sees the same underlying value, and a late subscriber doesn't retrigger the work that produced it, it just joins in progress.

What are the Flow builders in Kotlin?

Tier: CommonDifficulty: Easy

Kotlin gives you a handful of ways to build a Flow, the difference is where the values come from.

  • flowOf(1, 2, 3) builds a flow from a fixed, known set of values.
  • (1..5).asFlow() is an extension that turns an existing collection or range into a flow.
  • flow { emit(value) } is the general purpose builder, you write arbitrary suspending code and call emit whenever you have a value.
  • channelFlow { send(value) } is for when values can arrive from multiple coroutines at once, it uses send instead of emit because it's backed by a channel.
  • callbackFlow { trySend(value) } is a specialized channelFlow for wrapping listener based, non coroutine APIs, like a location listener, into a flow.
fun downloadProgress(): Flow<Int> = flow {
    for (percent in 0..100 step 10) {
        delay(200)
        emit(percent)
    }
}

flow { } covers almost everything you'll write day to day. Reach for channelFlow or callbackFlow specifically when emission needs to happen from a different coroutine or a callback than the one running the builder body, since flow { } only allows emission from the caller's own coroutine.

What is callbackFlow and how do you convert a callback to Flow?

Tier: CommonDifficulty: Medium

callbackFlow is a Flow builder for wrapping a listener based, callback style API into a Flow, so the rest of the code can use ordinary Flow operators on it instead of registering and managing the listener by hand.

fun locationUpdates(locationManager: LocationManager): Flow<Location> = callbackFlow {
    val listener = object : LocationListener {
        override fun onLocationChanged(location: Location) {
            trySend(location)
        }
    }
    locationManager.requestLocationUpdates(listener)
    awaitClose { locationManager.removeUpdates(listener) }
}

Inside the block you register the callback, and every time it fires you call trySend instead of emit, because the callback runs outside the flow's own coroutine and trySend is the non suspending, thread safe way to push a value from there. awaitClose is required, it suspends until the flow is cancelled or its collector finishes, and that's where you unregister the listener so it doesn't keep firing after nobody is collecting anymore.

Forgetting awaitClose is the classic bug here. The compiler won't stop you, but the flow completes right after the first emission, and the listener stays registered with nothing consuming its callbacks anymore. That's a listener leak that's easy to miss in review, since the app doesn't crash, it just keeps doing wasted work in the background.

Operators

Operators are where Flow questions usually go, because an interviewer can tell very quickly whether you have used them or only read about them.

What are the commonly used operators in Kotlin Flow (filter, map, zip, debounce, distinctUntilChanged, flatMapLatest)?

Tier: CommonDifficulty: Easy

These six show up constantly in real Android code, mostly for turning raw input, like search text or repository results, into exactly what the UI needs.

  • map transforms each emitted value into something else, like turning a network DTO into a UI model.
  • filter drops values that don't match a predicate, like throwing out blank search queries.
  • zip pairs up emissions from two flows and combines them, useful for waiting on two network calls together.
  • debounce waits for a pause between emissions before letting one through, the standard fix for search as you type.
  • distinctUntilChanged drops a value if it's equal to the one right before it, so the UI doesn't rebuild for a value that didn't actually change.
  • flatMapLatest switches to a new inner flow whenever a new value arrives upstream, canceling whatever the previous inner flow was doing.
searchQuery
    .debounce(300)
    .distinctUntilChanged()
    .filter { it.isNotBlank() }
    .flatMapLatest { query -> repo.search(query) }
    .collect { results -> render(results) }

The two that trip people up are flatMapLatest and zip, because both combine flows but do opposite things. zip waits for both sides to produce a value, flatMapLatest throws away whatever the previous side was doing. Knowing which one to reach for is really a question about whether you want every result or only the newest one.

What does flowOn do in Kotlin Flow?

Tier: CommonDifficulty: Medium

flowOn changes which coroutine context the upstream part of the flow runs on, without changing the context the collector itself runs in.

flow {
    emit(heavyDatabaseQuery()) // runs on Dispatchers.IO
}
.flowOn(Dispatchers.IO)
.collect { result ->
    updateUi(result) // still runs on whatever dispatcher called collect
}

Everything above flowOn in the chain, the builder and any operators before it, moves to the dispatcher you pass in. Everything below it, including collect, stays on the original context. That's why it's so common to see a repository return a flow built with flow { }, followed by .flowOn(Dispatchers.IO), so the disk or network work happens off the main thread while the ViewModel is still free to collect it on Dispatchers.Main without an explicit withContext call.

One restriction worth knowing, you can't call withContext inside the flow { } builder itself to switch dispatchers, that breaks context preservation and Kotlin flags it at compile time. flowOn is the operator built to do that job safely, and it only needs to appear once in the chain, it affects everything upstream of it, not just the operator directly above it.

What is the difference between collect and collectLatest in Kotlin Flow?

Tier: CommonDifficulty: Medium

collect processes every emission to completion, in order, before it moves on to the next one. collectLatest behaves the same when values arrive slowly, but if a new value shows up while the lambda for the previous one is still running, it cancels that in progress work and starts over with the new value.

flow {
    emit(1); delay(100)
    emit(2)
}.collectLatest { value ->
    println("start $value")
    delay(200)
    println("done $value") // never prints for 1, cancelled by 2
}

With plain collect, both "done 1" and "done 2" would print, in order. With collectLatest, emitting 2 before the block for 1 finishes cancels that block outright, so "done 1" never runs.

That makes collectLatest the right choice when only the newest value matters and the work reacting to it is cancellable, like updating a UI based on the latest search result. It's the wrong choice when every emission needs to be fully handled, like writing each one to a database, where dropping partial work would lose data.

What is the difference between flatMapConcat, flatMapMerge and flatMapLatest in Kotlin Flow?

Tier: CommonDifficulty: Medium

All three take each upstream value, map it to a new inner flow, and flatten the result into a single stream, they differ only in how they handle overlapping inner flows.

  • flatMapConcat runs the inner flows one at a time, in order, it fully collects one before starting the next.
  • flatMapMerge runs the inner flows concurrently and interleaves their emissions as they arrive, with a configurable concurrency limit.
  • flatMapLatest starts a new inner flow for each upstream value and cancels whatever inner flow was still running from the previous one.
searchQuery.flatMapLatest { query -> repo.search(query) } // cancels stale searches
userIds.flatMapMerge { id -> repo.fetchUser(id) }          // fetch several users in parallel
pages.flatMapConcat { page -> repo.loadPage(page) }        // load pages strictly in order

The one that shows up most in interviews is flatMapLatest, because it's the operator behind instant search, only the newest query's result should ever reach the UI. flatMapConcat is for when order matters more than speed, like paginated loads that have to stay sequential. flatMapMerge is for independent, unordered work, like fetching details for several IDs at once, where results can arrive in whatever order they finish.

StateFlow & SharedFlow

StateFlow and SharedFlow come up in almost every Android interview that touches Flow, usually followed by how they compare to LiveData.

What is the difference between Flow, StateFlow and SharedFlow?

Tier: EssentialDifficulty: Medium

Plain Flow is cold, its producer block runs on demand and fresh for every collector. StateFlow and SharedFlow are both hot, they run independent of whether anything is collecting, and multiple collectors share the same emissions instead of each triggering their own run.

  • Flow has no concept of a current value, it's a stream you subscribe to and it either emits or it doesn't.
  • StateFlow always holds exactly one current value, requires an initial value up front, and only emits again when the new value differs from the last one, since it's built on equality based conflation.
  • SharedFlow has no required initial value and no automatic deduplication, it just replays whatever replay count you configure, zero by default, to new subscribers, and it can emit the same value twice in a row.
private val _uiState = MutableStateFlow<UiState>(UiState.Loading) // state, one current value
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

private val _events = MutableSharedFlow<Event>()                  // one shot events
val events: SharedFlow<Event> = _events.asSharedFlow()

StateFlow is for state, screen data that should always have a current value, and where a late subscriber, like an Activity resubscribing after rotation, should immediately see what's already there. SharedFlow is for events, a Snackbar message or a one time navigation trigger, things that shouldn't replay to a subscriber that joins late and shouldn't get collapsed just because two of them happen to look the same.

Read more StateFlow and SharedFlow (opens in a new tab)

What is the difference between StateFlow and LiveData in Android?

Tier: EssentialDifficulty: Medium

Both hold a current value and notify observers whenever it changes, the real differences are where each one is safe to use and what happens when nobody is watching.

  • LiveData is lifecycle aware by construction, it automatically stops delivering updates to an observer whose lifecycle has moved past STARTED, and cleans up on its own when the lifecycle is destroyed. StateFlow has no idea what a lifecycle is, collecting it with a plain lifecycleScope.launch keeps collecting even while the view is stopped, unless you wrap it in repeatOnLifecycle(Lifecycle.State.STARTED), or collect it with collectAsStateWithLifecycle() in Compose.
  • LiveData is an Android framework type, StateFlow is a plain Kotlin coroutines type with no Android dependency, so it works in shared, multiplatform, or pure domain code where LiveData can't go.
  • LiveData only ever delivers on the main thread, and its API is split between setValue on the main thread and postValue from a background thread. StateFlow has a single .value property you can set from anywhere, and it delivers on whatever dispatcher the collector is running on.
  • StateFlow gets the full Flow operator set, map, combine, debounce, and so on. LiveData only has Transformations.map and Transformations.switchMap, which cover far less ground.
// StateFlow needs an explicit lifecycle guard, LiveData does not
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state -> render(state) }
    }
}

That repeatOnLifecycle line is the crux of it. LiveData gave you lifecycle safety for free, StateFlow gives you a more powerful, more testable, Android independent type but pushes the lifecycle awareness back onto you. Skipping it doesn't crash anything, it just means the collector keeps running, and the work behind it keeps happening, while the screen is in the background.

Read more StateFlow and SharedFlow (opens in a new tab)

What is the difference between stateIn and shareIn in Kotlin Flow?

Tier: CommonDifficulty: Hard

Both convert a cold Flow into a hot one that keeps running in a given scope and shares its emissions with multiple collectors, the difference is which hot type comes out the other end. stateIn produces a StateFlow, so it needs an initial value and only exposes the current one. shareIn produces a SharedFlow, so it needs a replay count instead and can hold onto more than just the latest value.

val uiState: StateFlow<UiState> = repository.observeData()
    .map { UiState.Success(it) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)

val events: SharedFlow<Event> = source
    .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)

Both take the same three arguments, a CoroutineScope to run in, a SharingStarted policy that decides when the upstream flow actually starts and stops, and either an initial value or a replay count. SharingStarted.WhileSubscribed(5000) is the one to reach for in a ViewModel, it keeps the upstream flow alive for five seconds after the last collector goes away, which survives a configuration change without restarting an expensive query or network call.

Reach for stateIn when you're exposing state that should always have a current value, screen data being the obvious case. Reach for shareIn when you're exposing a raw event stream where a current value doesn't make sense, or where you specifically want to replay more than just the last one.

Flow in Practice

How do you use Room Database with Kotlin Flow?

Tier: CommonDifficulty: Easy

You give the DAO method a Flow return type, and Room generates the streaming implementation itself, no flow { } wrapper required.

@Dao
interface UserDao {
    @Query("SELECT * FROM user WHERE isFavorite = 1")
    fun getFavorites(): Flow<List<User>>
}

Room watches the tables that query touches and re-runs it automatically whenever a write happens, insert, update or delete, then emits the fresh result on the flow. Collecting it in the ViewModel is the same as any other flow.

val favorites: StateFlow<List<User>> = userDao.getFavorites()
    .flowOn(Dispatchers.IO)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

That's the real win over calling a suspend fun getFavorites(): List<User> once, the UI stays in sync with the database on its own. Add a row somewhere else in the app and every screen observing that Flow updates without you writing any refresh logic.

How do you implement instant search using Kotlin Flow operators?

Tier: CommonDifficulty: Medium

You chain a small set of operators onto the flow of search text so that each keystroke doesn't hit the network on its own, and only the newest query's result reaches the UI.

  • debounce(300) waits for a pause in typing before letting a value through, so a, ab, abc typed in half a second only ever fire once.
  • distinctUntilChanged() drops a value if it's the same as the one before it, so pressing a key and immediately undoing it costs nothing.
  • filter { it.isNotBlank() } throws away empty queries before they ever reach the network call.
  • flatMapLatest { query -> searchRepo.search(query) } cancels the in flight search whenever a new query arrives, so a slow response for "ap" can never overwrite the result for "apple".
searchQuery
    .debounce(300)
    .distinctUntilChanged()
    .filter { it.isNotBlank() }
    .flatMapLatest { query -> searchRepo.search(query) }
    .flowOn(Dispatchers.IO)
    .collect { results -> _uiState.value = results }

flatMapLatest is the operator doing the real work here, debounce and filter are just cutting down how often it has to run. Without it, a user typing quickly could still see an older, slower response land after a newer one, showing stale results for whatever they typed last.

How does exception handling work in Kotlin Flow?

Tier: CommonDifficulty: Medium

An exception thrown anywhere upstream, in the flow builder or in an intermediate operator, cancels the flow and can be caught with the catch operator.

flowOf(1, 2, 3, 0, 4)
    .map { 10 / it }
    .catch { e -> emit(-1) }
    .collect { println(it) }

catch only sees exceptions raised upstream of where it's placed in the chain, it never catches anything thrown inside the collect block itself. That block runs on the collector's own coroutine, so if you need to guard code you write inside collect, you wrap it in a regular try and catch, or move the risky work into an operator like map before the catch in the chain.

Whether the flow finishes normally or fails, onCompletion runs either way, which makes it the right place for cleanup like hiding a loading spinner.

flow.onCompletion { cause -> hideLoading() }
    .catch { e -> showError(e) }
    .collect { data -> render(data) }

One rule worth remembering, catch can only emit a replacement value when it sits before the terminal operator, and it shouldn't be used to swallow an exception you don't understand, rethrow anything you don't expect so the caller still finds out.

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

Flow Basics & Builders

What is channelFlow and how does it differ from callbackFlow?

Tier: Less commonDifficulty: Hard

channelFlow is a Flow builder backed by a channel, which lets you send values from more than one coroutine at the same time. callbackFlow is a thin specialization of channelFlow, built specifically for wrapping listener based callback APIs, with extra checks that catch a missing awaitClose at compile time.

fun <T> merge(a: Flow<T>, b: Flow<T>): Flow<T> = channelFlow {
    launch { a.collect { send(it) } }
    launch { b.collect { send(it) } }
}

Reach for channelFlow when the values genuinely come from concurrent work, like merging two flows or fanning work out across several coroutines and sending each result back as it finishes. flow { } can't do this, it only allows emission from the single coroutine running the builder body, channelFlow relaxes that restriction by routing everything through a channel.

Reach for callbackFlow specifically when the source is a non coroutine, listener based API rather than other coroutines. Functionally the two are close, callbackFlow is channelFlow with trySend, awaitClose enforcement, and a couple of API differences aimed at exactly that use case. If you're not wrapping a callback, channelFlow is the more general and more idiomatic choice.

Operators

What are terminal operators in Kotlin Flow?

Tier: Less commonDifficulty: Easy

A terminal operator is what actually starts a flow. Everything before it, the builder and the intermediate operators, is just a description of work, nothing runs until a terminal operator connects to it and asks for values.

  • collect is the most common one, it suspends and runs a lambda for every emitted value.
  • first() and firstOrNull() collect just the first value and then cancel the flow.
  • single() expects exactly one emission and throws if it sees zero or more than one.
  • toList() and toSet() collect every value into a collection.
  • reduce and fold collapse the whole stream into one accumulated value, fold takes an initial value, reduce uses the first emission as its seed.
val total = (1..5).asFlow().reduce { a, b -> a + b } // 15

The reason this distinction matters in an interview, a Flow with no terminal operator attached does nothing at all, not even the side effects in the builder run. Calling map or filter on a flow and forgetting to collect it is a silent no-op, not a bug that throws, which makes it an easy one to miss in code review.

How do you use the Flow zip operator for parallel multiple network calls?

Tier: Less commonDifficulty: Medium

You put each network call behind its own Flow and combine them with zip, so both requests fire at the same time and you get a single callback once both responses are in.

fun getUsers(): Flow<List<User>> = flow { emit(api.getUsers()) }
fun getMoreUsers(): Flow<List<User>> = flow { emit(api.getMoreUsers()) }

getUsers()
    .zip(getMoreUsers()) { users, moreUsers -> users + moreUsers }
    .flowOn(Dispatchers.IO)
    .catch { e -> showError(e) }
    .collect { allUsers -> render(allUsers) }

Both flow { } blocks start executing as soon as collection begins, so Retrofit fires both HTTP requests immediately rather than waiting for the first to finish. zip then blocks on the combining lambda until both sides have a value, so the collector only runs once, with both responses merged.

One thing worth calling out, if either call throws, the whole zipped flow fails. If you want the first call to still show results when the second one fails, catch inside that individual flow before it reaches zip, not after.

How does the retry operator work in Kotlin Flow?

Tier: Less commonDifficulty: Medium

retry re-collects the upstream flow from scratch whenever it throws, up to a number of attempts you give it, and retryWhen does the same thing but hands you the exception and the attempt count so you can decide.

flow { emit(api.getUser()) }
    .retry(3) { cause -> cause is IOException }
    .catch { e -> emit(fallbackUser) }
    .collect { user -> render(user) }

The predicate on retry decides whether a given failure is worth retrying at all, here only a network error triggers another attempt, anything else falls straight through to catch. retryWhen is the version to reach for when you need backoff, since it's a suspend lambda and you can delay inside it before returning true.

flow { emit(api.getUser()) }
    .retryWhen { cause, attempt ->
        if (cause is IOException && attempt < 3) {
            delay(1000L * (attempt + 1))
            true
        } else {
            false
        }
    }
    .collect { user -> render(user) }

The part people forget, a retry restarts the entire upstream chain, not just the last step. If the flow starts with a network call and ends with a database write, retrying after the write fails means the network call runs again too, so retry only belongs on flows where re-running the whole chain is safe.

Flow in Practice

How do you use Retrofit with Kotlin Flow?

Tier: Less commonDifficulty: Easy

Retrofit doesn't return Flow on its own, so you write a suspend function for the network call and wrap it in the flow builder yourself.

interface ApiService {
    suspend fun getUsers(): List<User>
}

class UserRepository(private val api: ApiService) {
    fun getUsers(): Flow<List<User>> = flow {
        emit(api.getUsers())
    }
}

In the ViewModel, you collect it with the usual operators.

viewModelScope.launch {
    repository.getUsers()
        .flowOn(Dispatchers.IO)
        .catch { e -> _uiState.value = UiState.Error(e.message) }
        .collect { users -> _uiState.value = UiState.Success(users) }
}

The pattern is thin on purpose. The flow wrapper just turns a single suspend result into a one shot stream, which is worth it because it gives you catch, retry, and flowOn for free, and it keeps the repository's return types consistent with the rest of a Flow based data layer, like Room's query flows.

How do you run parallel tasks and get a callback when all are complete?

Tier: Less commonDifficulty: Medium

You launch each task as its own Flow and combine them with the zip operator, which waits until every upstream flow has emitted a value before it hands you the combined result.

private fun taskOne(): Flow<String> = flow { delay(2000); emit("A") }
private fun taskTwo(): Flow<String> = flow { delay(3000); emit("B") }

viewModelScope.launch {
    taskOne()
        .zip(taskTwo()) { a, b -> a + b }
        .flowOn(Dispatchers.Default)
        .catch { e -> showError(e) }
        .collect { combined -> render(combined) }
}

Both flows start collecting immediately, so taskOne and taskTwo run at the same time instead of one waiting for the other. zip only calls its combining lambda once both sides have emitted, so the collector fires exactly once, with both results in hand. It's the Flow equivalent of awaitAll on a list of async coroutines, just expressed as a stream.

For combining more than two sources, combine is the operator to reach for instead, since zip is strictly pairwise and needs a matching emission on both sides before it produces anything.