Kotlin Coroutines & Flow vs Dart Streams
Structured concurrency, suspend functions, Flow, StateFlow — and how they map to Dart
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Kotlin Coroutines and Flow are Android's async and reactive programming primitives. Understanding them alongside Dart's Futures, Streams, and Isolates positions you to work confidently in hybrid Flutter+Android codebases, write better platform channels, and discuss tradeoffs fluently in senior interviews.
Real-world relevance
In a fintech BankID integration: Kotlin coroutines handle the BankID polling loop (withContext(Dispatchers.IO)), StateFlow drives the Compose UI, and SharedFlow delivers one-time navigation events. The Flutter layer communicates via MethodChannel where the Kotlin coroutine posts results back on Dispatchers.Main. A Flutter engineer who understands this can debug the entire stack.
Key points
- Suspend Functions — Kotlin's suspend fun pauses execution without blocking a thread — identical concept to Dart's async/await. A suspend function can only be called from a coroutine or another suspend function, just as await requires an async context.
- Coroutine Builders — launch {} fires-and-forgets a coroutine (like unawaited Future in Dart). async {} returns a Deferred (like Dart's Future). runBlocking {} blocks the calling thread — used in tests, not production.
- Dispatchers — Dispatchers.Main runs on Android UI thread (≈ Dart main isolate). Dispatchers.IO optimised for I/O (≈ Dart Isolate.spawn with I/O work). Dispatchers.Default for CPU-intensive work (≈ compute() in Flutter).
- Structured Concurrency — Coroutines launched in a CoroutineScope are cancelled when the scope is cancelled. Android ViewModelScope and lifecycleScope auto-cancel coroutines when ViewModel is cleared or lifecycle ends. Dart lacks built-in structured concurrency — you manage cancellation manually.
- Cold Flows — Kotlin Flow is cold — code inside flow {} runs only when collected, one collector gets one independent execution. Identical to Dart's single-subscription Stream. Neither produces values until someone listens.
- Hot Flows — StateFlow — StateFlow holds a current value and replays it to new collectors. Equivalent to Dart's BehaviorSubject (rxdart) or a StreamController with a seeded value. Used for UI state: val uiState: StateFlow.
- Hot Flows — SharedFlow — SharedFlow has configurable replay cache and multiple collectors. Closer to Dart's StreamController.broadcast(). Used for one-time events (navigation, snackbars) that should not be replayed to late subscribers.
- LiveData vs Flow — LiveData is lifecycle-aware (auto-stops emission when UI is in background). Flow requires collectAsStateWithLifecycle() in Compose or launchIn(lifecycleScope) to achieve lifecycle safety. Modern Android prefers Flow; LiveData is legacy.
- Dart Streams vs Kotlin Flow — Dart Stream ≈ Kotlin Flow (cold, single subscriber). Dart StreamController.broadcast() ≈ Kotlin SharedFlow. Dart BehaviorSubject (rxdart) ≈ Kotlin StateFlow. Dart Isolate ≈ Kotlin coroutine on Dispatchers.Default/IO.
- withContext vs Isolate — Kotlin's withContext(Dispatchers.IO) switches the current coroutine to a background thread — lightweight, shared thread pool. Dart's Isolate.run() spawns a completely separate memory-isolated process — heavier, but true parallelism for CPU tasks.
Code example
// === KOTLIN SIDE ===
// Suspend function — equivalent to Dart async/await
suspend fun fetchClaims(): List<Claim> = withContext(Dispatchers.IO) {
api.getClaims() // Runs on background thread pool
}
// ViewModel with StateFlow
class ClaimsViewModel : ViewModel() {
// StateFlow — holds current state, replays to new collectors
private val _uiState = MutableStateFlow<ClaimsState>(ClaimsState.Loading)
val uiState: StateFlow<ClaimsState> = _uiState.asStateFlow()
// SharedFlow — one-time events (no replay)
private val _events = MutableSharedFlow<ClaimsEvent>()
val events: SharedFlow<ClaimsEvent> = _events.asSharedFlow()
init {
viewModelScope.launch { // Auto-cancelled when ViewModel is cleared
try {
val claims = fetchClaims()
_uiState.value = ClaimsState.Success(claims)
} catch (e: Exception) {
_uiState.value = ClaimsState.Error(e.message)
_events.emit(ClaimsEvent.ShowSnackbar("Failed to load"))
}
}
}
}
// Cold Flow — like Dart single-subscription Stream
fun pollBankId(token: String): Flow<BankIdStatus> = flow {
repeat(30) {
val status = bankIdApi.check(token)
emit(status)
if (status is BankIdStatus.Complete) return@flow
delay(2000)
}
}
// Collecting Flow with lifecycle safety (Compose)
@Composable
fun ClaimsScreen(vm: ClaimsViewModel = viewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
// ...
}
// === DART EQUIVALENT ===
// Future = suspend fun + async builder
Future<List<Claim>> fetchClaims() async {
return await api.getClaims();
}
// StreamController.broadcast = SharedFlow
final _events = StreamController<ClaimsEvent>.broadcast();
Stream<ClaimsEvent> get events => _events.stream;
// BehaviorSubject (rxdart) = StateFlow
final _uiState = BehaviorSubject<ClaimsState>.seeded(ClaimsState.loading());
Stream<ClaimsState> get uiState => _uiState.stream;
// Cold Stream = cold Flow
Stream<BankIdStatus> pollBankId(String token) async* {
for (int i = 0; i < 30; i++) {
final status = await bankIdApi.check(token);
yield status;
if (status is BankIdStatusComplete) return;
await Future.delayed(const Duration(seconds: 2));
}
}Line-by-line walkthrough
- 1. MutableStateFlow(Loading) — creates a hot flow with initial value, type-safe UI state container
- 2. viewModelScope.launch — coroutine tied to ViewModel lifecycle; auto-cancelled on ViewModel.onCleared()
- 3. withContext(Dispatchers.IO) — switches to background thread for network/disk work, returns to original dispatcher after block
- 4. _uiState.value = Success(claims) — thread-safe state update; all collectors receive the new value immediately
- 5. MutableSharedFlow() with no replay — emits one-time events; late subscribers miss them (correct for snackbars/navigation)
- 6. flow {} builder — cold; the repeat block runs fresh for each collector independently
- 7. collectAsStateWithLifecycle() — Compose extension that collects Flow only when UI is at least STARTED — equivalent to LiveData's lifecycle awareness
- 8. BehaviorSubject.seeded() — Dart rxdart equivalent of StateFlow; holds latest value and emits it to new subscribers immediately
Spot the bug
class SyncViewModel : ViewModel() {
fun startSync() {
GlobalScope.launch {
val result = repository.syncData()
_uiState.value = UiState.Success(result)
}
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Kotlin Coroutines — official guide (Kotlin Docs)
- Kotlin Flow (Kotlin Docs)
- StateFlow and SharedFlow (Android Docs)
- Dart Streams (Dart Docs)
- rxdart package (pub.dev)