Lesson 43 of 77 advanced

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

Think of Dart and Kotlin as two different kitchens. Both can cook async meals (coroutines/Futures), serve live menus that update (Flow/Streams), and keep a chalkboard of today's specials (StateFlow/StreamController.broadcast). The recipes differ but the cooking concepts are the same — once you know one kitchen, you can learn the other by mapping what you already know.

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

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. 1. MutableStateFlow(Loading) — creates a hot flow with initial value, type-safe UI state container
  2. 2. viewModelScope.launch — coroutine tied to ViewModel lifecycle; auto-cancelled on ViewModel.onCleared()
  3. 3. withContext(Dispatchers.IO) — switches to background thread for network/disk work, returns to original dispatcher after block
  4. 4. _uiState.value = Success(claims) — thread-safe state update; all collectors receive the new value immediately
  5. 5. MutableSharedFlow() with no replay — emits one-time events; late subscribers miss them (correct for snackbars/navigation)
  6. 6. flow {} builder — cold; the repeat block runs fresh for each collector independently
  7. 7. collectAsStateWithLifecycle() — Compose extension that collects Flow only when UI is at least STARTED — equivalent to LiveData's lifecycle awareness
  8. 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?
GlobalScope is considered harmful in Android. Why, and what should replace it?
Show answer
Bug: GlobalScope.launch creates a coroutine that lives for the entire application lifetime — it is not cancelled when the ViewModel is cleared or when the user navigates away. This causes memory leaks (the ViewModel is retained by the coroutine) and potential crashes if _uiState is updated after the UI is destroyed. Fix: replace GlobalScope.launch with viewModelScope.launch — it is automatically cancelled when ViewModel.onCleared() is called, providing proper structured concurrency.

Explain like I'm 5

Imagine two chefs (Kotlin and Dart) making the same dish — async soup. They both know how to wait for ingredients without standing still (coroutines/futures). Kotlin has a special soup tureen that always has the last batch warm and ready (StateFlow) while Dart uses a similar pot from a recipe package (BehaviorSubject). The kitchen layout is different but the meal is the same.

Fun fact

Kotlin coroutines were inspired partly by research into communicating sequential processes (CSP) from 1978. Dart's async/await was added in Dart 1.9 (2015) following the same cooperative multitasking model as JavaScript's Promises/async-await. Despite being created independently, both settled on nearly identical syntax — a testament to how well the async/await abstraction maps to human intuition.

Hands-on challenge

Draw the mapping table for a senior interview: Dart Future ↔ Kotlin ?, Dart Stream (single) ↔ Kotlin ?, Dart broadcast Stream ↔ Kotlin ?, Dart BehaviorSubject ↔ Kotlin ?, Dart Isolate ↔ Kotlin ?. Then explain in two sentences why Kotlin's structured concurrency solves a problem that Flutter developers must solve manually.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery