Android apps constantly do work that takes time such as loading data from a server or reading a database or processing an image and that work must not block the main thread that draws the interface. Coroutines provide a way to write asynchronous code that looks like ordinary sequential code while still running heavy work on a background thread pool. Flow builds on coroutines to represent a stream of values over time so a screen can observe data changes without manual callbacks and listener management. Together they replace older patterns that relied on threads and callbacks that were harder to cancel and harder to test in a reliable way. This guide explains suspend functions and dispatchers and flows in practical terms for everyday Android development.
A coroutine is a lightweight task that can suspend without blocking its thread and resume later when its work completes or when new data arrives from a source. Suspension differs from blocking because a suspended coroutine frees its thread for other work while it waits whereas a blocked thread sits idle and wastes resources during a long wait. Structured concurrency ties coroutines to a scope so child tasks cancel automatically when their parent scope ends and that rule prevents runaway background work after a screen disappears. That automatic cancellation is a major advantage over raw threads that keep running unless a developer remembers to stop them explicitly in every exit path. Learning structured concurrency early prevents leaks and wasted battery use that users notice in apps that run background work carelessly.
Suspend Functions and Dispatchers
A suspend function can pause and resume and it can only be called from another suspend function or from a coroutine builder that creates a scope for asynchronous work. That restriction keeps asynchronous code visible in the type system so a reader can see which functions may take time and which functions return immediately without waiting. Dispatchers decide which thread pool runs a coroutine and the main dispatcher runs interface work while the input output dispatcher suits disk and network work and the default dispatcher suits CPU heavy computation. Switching dispatchers is easy with a context change and that flexibility lets a single coroutine move between background work and interface updates without manual thread handling. Use the right dispatcher for each job because running CPU work on the input output pool or network work on the main thread both create performance problems in different ways.
View model scope is a built in scope tied to a view model lifetime so coroutines launched there cancel automatically when the view model is cleared after the user leaves a screen permanently. Lifecycle scope works similarly for activities and fragments but it cancels when the lifecycle owner is destroyed so it suits work tied to a visible screen rather than longer lived screen data. Avoid global scope for ordinary app work because it has no automatic cancellation tied to any screen and tasks launched there can outlive the feature that started them. Choose a scope that matches the lifetime of the work and document that choice when the work must survive a screen change for a legitimate reason. Proper scope choice is the simplest way to keep background work predictable and safe in a real app.
- Use suspend functions for operations that may take time such as network or database calls.
- Run interface updates on the main dispatcher and heavy work on a background dispatcher.
- Launch screen work in view model scope so it cancels when the view model clears.
- Avoid global scope for ordinary work because it lacks automatic lifecycle cancellation.
- Switch dispatchers explicitly when a coroutine moves between work types.
Flow Streams of Data Over Time
A flow emits values over time and a collector receives those values as they arrive without polling a source repeatedly for changes that may not exist yet. Cold flows start work when collected and each collector gets its own independent stream which suits database queries and one time data loads that should restart for each observer. Hot flows such as state flow and shared flow keep a current value or replay recent values to new collectors and they suit screen state that many observers need to share. State flow always holds a current value so a new screen can display something immediately instead of waiting for the next emission after it starts observing. Shared flow suits events that should reach active collectors without holding a permanent current value that might be replayed incorrectly later.
Operators transform flows in a declarative way so you can map data and filter values and combine streams without writing manual listener code that is hard to maintain. A map operator converts each emitted value while a filter operator drops values that do not meet a condition and a combine operator merges two streams into one derived value for a screen. Flow operators run in a structured way and cancellation propagates through the chain so stopping collection stops upstream work that is no longer needed by any observer. That behaviour saves resources because a database query or a network subscription does not continue after the last screen stops listening to its results. Learning a small set of operators well covers most screen data needs without requiring exotic combinations that are hard to debug.
StateFlow SharedFlow and Screen State
Screen state often fits state flow because a screen needs a current value at all times such as a loading flag or a list of items or an error message that may be empty. Expose state flow as an immutable flow from a view model while keeping the mutable version private so only the view model can change state through controlled methods. That encapsulation prevents screens from accidentally changing state in ways that bypass business logic and validation that the view model should enforce. Collect state flow in the interface with a lifecycle aware collector so updates pause when the screen is in the background and resume cleanly when it returns to the foreground. Lifecycle aware collection prevents wasted work and prevents updates from reaching a view that is no longer visible to the user.
Events that should happen once such as navigation or a toast message need careful handling because state flow replays its current value to new collectors and could trigger the same event again after rotation. Shared flow with no replay suits one time events when collectors are active at emission time while other patterns use a consumed flag for events that must survive a brief collector gap. Choose the pattern that matches your event semantics and test rotation and process recreation to ensure events do not duplicate unexpectedly. You can explore interface state further in Jetpack Compose for modern declarative UI because Compose collects flows naturally for screen state. You can also review lifecycle rules in Android app lifecycle and fragment states because flow collection must respect lifecycle boundaries.
Cancellation Errors and Timeouts
Cancellation is cooperative in coroutines because a coroutine checks for cancellation at suspension points and cleans up resources before it stops its work. Long running loops should check cancellation explicitly when they do CPU work without suspension points so they do not ignore a cancellation request for a long time. Use finally blocks for cleanup such as closing a file or releasing a resource because finally runs during cancellation and ensures that resources do not leak after a task ends early. Timeouts protect users from operations that hang indefinitely by cancelling work after a reasonable period and letting the interface show a retry option instead of an endless loading spinner. Combine timeouts with retry logic carefully so a failing operation does not retry forever and flood a server with repeated requests.
Error handling in flows uses catch operators and result types that let a screen distinguish a data error from a successful empty result in a clear way. A catch operator can emit a fallback value or rethrow an error after logging context that helps debugging without exposing sensitive data in logs. Avoid swallowing errors silently because a screen that shows no data and no error leaves users confused about whether loading is still happening or has failed completely. Supervisor scopes allow child failures to stay isolated so one failing background task does not cancel sibling tasks that could still succeed independently. Thoughtful error and cancellation handling turns asynchronous code from a source of mysterious bugs into a reliable part of the app.
Testing and Practical Patterns
Testing coroutines uses test dispatchers that let tests control virtual time and run asynchronous code deterministically without real delays that make tests slow and flaky. A test dispatcher can advance time instantly so a delay based operation completes immediately in a test while still verifying that the delay logic exists in production code. Fake repositories that return flows with controlled values let view model tests verify state transitions without a database or a network server. Keep coroutine code testable by injecting dispatchers where needed and by avoiding hard coded thread choices deep inside business logic that tests cannot control. Testable asynchronous code gives teams confidence to refactor background work without fear of breaking timing behaviour silently.
Practical patterns include launching work in view model scope for screen data and using repository flows for data that many screens share across the app. Avoid launching coroutines from interface code for business work because that work then lacks a clear owner and may survive longer than the screen that started it. Document flow types in code comments when a flow is hot or cold because that distinction affects how many times upstream work runs for each collector. You can apply these patterns to networking in efficient API networking with Retrofit and OkHttp where suspend functions are central. You can also apply them to storage in Room database for local SQLite storage where flows observe database changes automatically. With structured concurrency and flows your background work becomes calm and predictable instead of a tangle of threads and callbacks.
viewModelScope.launch {
try {
val user = repository.getUser()
_state.value = UiState.Success(user)
} catch (e: Exception) {
_state.value = UiState.Error("Could not load user")
}
}
The steps above follow the approach described in the official developer documentation for this platform and reflect common practice used by experienced developers when they build and test Android apps in a careful and organised way.


