← All problems

Mobile Core Concepts

Concurrency

Coroutines, structured concurrency, and Flow - Kotlin's concurrency model on Android, with the cancellation and scoping rules that keep work from leaking.

Free~20 min

Modern Android is built on coroutines. Room exposes suspend DAOs and Flows for queries. Retrofit returns suspend functions. WorkManager's CoroutineWorker is the default. Compose's LaunchedEffect is a coroutine scope. You cannot read a modern Android system-design problem - photo-gallery, messenger, crash-reporter - without already knowing this material. This page covers the parts of the model that show up in interviews: what suspend actually does, why every coroutine belongs to a scope, how cancellation propagates (and how to write code that cooperates with it), and the practical difference between cold Flow and hot StateFlow / SharedFlow. The final section gathers the pitfalls that catch even mid-level engineers.

Coroutines and suspend

A suspend function can pause without blocking its thread. The compiler rewrites it into a state machine (a Continuation) - what reads as sequential "do A, then do B" is "do A, register a callback to resume at B." A single thread can serve thousands of in-flight suspend calls.

// Blocks the calling thread for two seconds. On main, this ANRs.
fun fetchQuotes(): List<Quote> {
    Thread.sleep(2000)
    return api.getQuotes()
}
 
// Suspends; the thread is free to do other work while the delay elapses.
suspend fun fetchQuotes(): List<Quote> {
    delay(2000)
    return api.getQuotes()
}

suspend is not "async" in the JavaScript sense - it doesn't move work to another thread by itself. It runs on whatever dispatcher the caller is on. Moving work off the main thread is a separate step: either the caller wraps in withContext(Dispatchers.IO), or the suspending function does it internally.

The inverse - bridging suspending code back into blocking code - is runBlocking, which blocks the calling thread until the block completes. On the main thread that freezes the UI and ANRs; it is acceptable only in tests and main() entry points, never in app code.

Dispatchers

DispatcherUse for
MainUI updates, observable mutations, short logic
IONetwork, disk, database - anything that blocks on a syscall
DefaultCPU-intensive work (sorting, JSON parsing, large list transforms)
UnconfinedTests only - don't use in production

The split is about what kind of resource is scarce. Main is scarce by definition (one thread, drives all UI). IO uses an elastic thread pool sized to allow many blocking waits. Default is sized to CPU cores.

launch vs async vs withContext

  • launch { ... } fires a new coroutine and returns a Job immediately. Fire-and-forget; the result, if any, has to be pushed through a state holder.
  • async { ... }.await() fires a new coroutine and returns a Deferred<T>; await() suspends until the result is ready. Use when you want parallel work and a return value.
  • withContext(Dispatchers.IO) { ... } switches dispatcher, suspends the caller, runs the block, returns the result, resumes on the original dispatcher. Use to move part of a sequential function off the main thread without spawning a new coroutine.
// Sequential - one network call, then one DB write.
suspend fun saveQuote(id: String) {
    val quote = withContext(Dispatchers.IO) { api.getQuote(id) }
    withContext(Dispatchers.IO) { dao.insert(quote) }
}
 
// Parallel - two independent network calls; we wait for both.
suspend fun loadFeed() = coroutineScope {
    val posts = async { api.getPosts() }
    val ads   = async { api.getAds() }
    Feed(posts.await(), ads.await())
}

The mental model: launch is "go do this; I'll keep going." async is "go do this in parallel with what I'm about to do next; I'll join later." withContext is "pause me; do this on a different dispatcher; give me the answer."

Structured concurrency

Every coroutine belongs to a scope. Scopes form a tree. When a scope cancels, every coroutine in its subtree cancels. When a child fails with an exception (in a regular scope), the parent and its other children all cancel.

This is the guarantee that makes coroutine lifetimes predictable. viewModelScope is cancelled when the ViewModel is cleared - every coroutine started in it stops automatically. lifecycleScope is cancelled when the Lifecycle is destroyed. No manual unsubscribe, no explicit teardown.

Rendering diagram…

GlobalScope opts out of this model. A coroutine launched in GlobalScope outlives every Activity, ViewModel, and lifecycle - it's a leak waiting to happen. Use a scope tied to ProcessLifecycleOwner if you genuinely need app-lifetime work; reach for GlobalScope almost never.

supervisorScope - failures don't cancel siblings

In a regular coroutineScope, one child failure cancels the parent and every sibling. Sometimes that's wrong: if you're fetching posts and ads in parallel and the ads call fails, the posts shouldn't disappear.

supervisorScope {
    val posts = async { repository.fetchPosts() }
    val ads   = async { adsService.fetchAds() }
    PostsWithAds(
        posts = posts.await(),
        ads   = runCatching { ads.await() }.getOrNull(),
    )
}

Inside a supervisorScope, a child failure stays local - it surfaces when the parent calls await() and is ignored if you don't. Use it when sibling coroutines are independent.

Exception handling

launch propagates uncaught exceptions up the scope tree. async stores exceptions in the Deferred and rethrows them on .await() - an async { throw ... } that's never awaited won't surface until the parent scope completes, at which point it cancels the parent. For long-lived scopes, install a CoroutineExceptionHandler (viewModelScope + handler) to centralize uncaught-failure logging.

Cancellation cooperation

Coroutine cancellation is cooperative: the framework asks the coroutine to stop, but the coroutine has to check. Built-in suspend functions (delay, withContext, await, every Room DAO method, every Retrofit call) check at each suspension point. Pure CPU loops do not.

// Will ignore cancellation - the loop never suspends.
suspend fun crunch(rows: List<Row>): Int {
    var sum = 0
    for (r in rows) sum += expensive(r)   // no suspension point
    return sum
}
 
// Cooperates - ensureActive throws CancellationException if cancelled.
suspend fun crunch(rows: List<Row>): Int = withContext(Dispatchers.Default) {
    var sum = 0
    for (r in rows) {
        ensureActive()                    // or: if (!isActive) return@withContext sum
        sum += expensive(r)
    }
    sum
}

Use ensureActive() inside hot CPU loops; yield() if you also want to let other coroutines on the same dispatcher run. withTimeout(2000) { ... } and withTimeoutOrNull(2000) { ... } enforce a deadline by cancelling the block when the timer fires - effective only against cooperative code.

CancellationException is special: it's not an "error" in the parent's view, it's the cancel signal itself. A bare try { ... } catch (e: Exception) { ... } swallows it - the classic bug. Either rethrow (throw e), or use catch (e: CancellationException) { throw e } catch (e: Exception) { ... }.

Flow - cold and hot

Flow<T> is the suspend-aware reactive stream. Two flavors with very different semantics.

Cold flows run their builder block from scratch for every collector. Two collect calls = two independent executions. Room DAO Flows are cold; each subscriber re-runs the query. Retrofit-as-Flow is cold; each subscriber makes its own HTTP call.

Hot flows (StateFlow, SharedFlow) share one execution among all collectors and emit regardless of whether anyone's listening. They're the right shape for "UI state of a screen": one source of truth, many observers.

Rendering diagram…

StateFlow vs SharedFlow

  • StateFlow<T> - always has a current value, new collectors immediately receive it, deduplicates consecutive equal emissions (Any.equals). Use for screen UI state.
  • SharedFlow<T> - configurable replay (default 0) and buffer (default 0). New collectors get no value until something is emitted. Use for one-time events (navigation, snackbar).
class FeedViewModel(repo: FeedRepository) : ViewModel() {
    val state: StateFlow<FeedState> = repo.feed()
        .map(::toUiState)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FeedState.Loading)
 
    private val _events = MutableSharedFlow<FeedEvent>(extraBufferCapacity = 1)
    val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
}

stateIn converts a cold Flow into a hot StateFlow. The Started mode is the critical choice:

Started modeBehaviorUse for
WhileSubscribed(5_000)Active only while collected; 5 s grace covers rotationScreen-level UI state
EagerlyStarts immediately, never stopsApp-wide state (theme, auth)
LazilyStarts on first collector, never stopsRare

WhileSubscribed(5_000) is the right default for screens. Without the grace window, rotating the device tears down the flow and re-fires the upstream query - wasteful, and visible as a flicker.

Operators that earn their keep

  • combine(a, b) { ... } emits when either source emits, using the latest from each. Right for independent DB Flows feeding one UI state.
  • flatMapLatest { ... } cancels the previous inner flow on each new emission. The standard search / debounce pattern.
  • flowOn(Dispatchers.IO) changes the upstream dispatcher without affecting the collector's dispatcher.
val results: Flow<List<Result>> = query
    .debounce(300)
    .flatMapLatest { q -> repository.search(q) }  // previous query cancelled on new keystroke
    .flowOn(Dispatchers.IO)

Mutex and atomic state

Coroutines need coroutine-aware synchronization primitives. Mutex.withLock { ... } is a suspending lock: a coroutine waiting for it pauses without blocking a thread. synchronized(lock) { ... } is a blocking lock - on the main thread, that's an ANR.

private val mutex = Mutex()
private var cachedToken: String? = null
 
suspend fun getValidToken(): String = mutex.withLock {
    cachedToken?.let { return@withLock it }
    val fresh = api.refreshToken()
    cachedToken = fresh
    fresh
}

The pattern matters - this is coalescing, not just locking. Three concurrent 401s arrive, three coroutines call getValidToken(). The first acquires the mutex, refreshes, caches, releases. Coroutines 2 and 3 wake up, take the lock, see the cached token, return it. Without the mutex, all three fire parallel refresh calls.

For single-flag state that doesn't need a critical section, AtomicBoolean / AtomicReference are lock-free and cheaper:

private val intentionallyClosed = AtomicBoolean(false)
 
fun disconnect() {
    intentionallyClosed.set(true)
    webSocket.cancel()
}
 
override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) {
    if (!intentionallyClosed.get()) scheduleReconnect()
}

Never mix synchronized with coroutines. It blocks the thread, defeats suspension, and on Main is an ANR.

Used in

Concurrency is the prereq for every system-design problem on the site. Specific touch points:

  • Crash Reporter SDK - the background uploader is a CoroutineWorker; the breadcrumb buffer's add / snapshot boundary is the Mutex pattern; the JVM uncaught-exception handler is synchronous on the dying thread and chains to the previous handler before returning.
  • Photo Gallery App - the UploadWorker is a CoroutineWorker, ChunkedUploader.upload is a long-running suspend function that cooperates with cancellation (the OS may kill the worker), and the UI binds to Room's Flow<UploadRecord> for progress.
  • Messenger App - WebSocket lifecycle, send-queue, and read-receipt streams are modeled as hot SharedFlows; the typing-indicator debounce is flatMapLatest.
  • Newsfeed App - combine of cached + remote Flow drives the UI; WhileSubscribed(5_000) is the right stateIn mode for the feed screen.
  • Doc Editor App - the operational-transform queue is serialized by a Mutex; remote ops arrive via a hot SharedFlow.

Done reading? Mark it so it sticks in your dashboard.