← Back to books

Chapter 2: Async Essentials — Coroutines and Flow for Network Calls

At the end of the last chapter we ran into a hard rule: you cannot do networking on the main thread. A network call is slow and unpredictable, the main thread is responsible for keeping the UI alive, and Android will crash your app with NetworkOnMainThreadException rather than let you freeze the screen.

So networking has to happen somewhere else — in the background — and the result has to make its way back to the UI thread to be displayed. That "go do slow work elsewhere, then come home to update the screen" problem is called asynchronous programming, or async for short. It used to be one of the most painful parts of Android development. Kotlin's answer, coroutines, makes it so clean that async code ends up looking almost like ordinary top-to-bottom code.

This chapter is the bridge between "I understand HTTP" and "I can call a server from an app." We are still not going to touch Retrofit or Ktor — but every concept here is one you will use in every single network call for the rest of the book. Retrofit's functions are suspend functions. Ktor's requests are suspend functions. Realtime streams are Flows. If coroutines and Flow are solid, the networking chapters will feel easy. If they are shaky, networking will feel like guesswork. So let's make them solid.

The problem, concretely

Picture the naive, wrong version of loading Pulse messages:

// DON'T do this — it crashes.
fun onScreenOpened() {
    val messages = fetchMessagesFromServer() // blocks for maybe 2 seconds
    showMessages(messages)
}

If fetchMessagesFromServer() runs on the main thread, the app freezes for those two seconds — no scrolling, no button responds — and Android throws NetworkOnMainThreadException. We need fetchMessagesFromServer() to run in the background, and then we need showMessages() to run back on the main thread once the data arrives.

Before coroutines, developers solved this with callbacks: "go fetch the messages, and when you're done, call this function." It worked, but nesting several async steps produced a tangle known as callback hell:

// The old, painful way — nested callbacks.
login { token ->
    fetchChannels(token) { channels ->
        fetchMessages(channels.first()) { messages ->
            showMessages(messages) // three levels deep, and error handling is a nightmare
        }
    }
}

Each step is indented inside the previous one, errors are hard to route, and reading the logic top to bottom is impossible. Coroutines exist to make that same sequence read like a simple list of steps.

What a coroutine is

A coroutine is a piece of work that can pause and resume without blocking the thread it is running on. That single sentence is the whole magic, so let's unpack it.

When ordinary code makes a slow call — like waiting for a server — the thread just sits there, blocked, doing nothing useful until the answer arrives. A thread is an expensive resource; having one frozen and waiting is wasteful, and if it is the main thread, it is catastrophic.

A coroutine can instead suspend at the slow point. Suspending means it politely steps aside and frees the thread to do other things while it waits. When the server finally responds, the coroutine resumes right where it left off. The thread was never blocked; it went off and did other work in the meantime. This is why coroutines are described as enabling code that is asynchronous but reads sequentially. You write what looks like straight-line, top-to-bottom code, and the pausing-and-resuming happens invisibly underneath.

Here is the same message-loading, done right:

suspend fun loadMessages() {
    val messages = fetchMessagesFromServer() // suspends here, doesn't block
    showMessages(messages)                   // resumes here when data arrives
}

It reads exactly like the naive version — two lines, top to bottom — but it does not freeze the UI. That readability is the entire point of coroutines.

The suspend keyword

Notice the new keyword: suspend. A function marked suspend is one that might pause. It is Kotlin's way of labelling "this function could take a while and suspend the coroutine partway through."

suspend fun fetchMessagesFromServer(): List<Message> {
    // ... network work that may take time ...
}

Two rules govern suspend functions, and they are the two facts juniors trip over most:

A suspend function can only be called from another suspend function, or from inside a coroutine. You cannot call one from an ordinary function. This makes sense — only code that itself knows how to pause can call code that pauses. This "suspend spreads upward" property is normal and expected; it is not you doing something wrong.

Calling a suspend function does not, by itself, start a background thread. Suspending is about pausing, not about which thread you are on. Those are two separate concerns, and conflating them is a classic source of confusion. We will handle the "which thread" question with dispatchers shortly. For now: suspend = "can pause," nothing more.

When we reach Retrofit and Ktor, their functions will be suspend functions. That is why you need to understand this keyword before touching them — the whole design of both libraries assumes you are calling them from a coroutine.

Starting a coroutine: scopes and builders

If a suspend function can only be called from a coroutine, how does the first coroutine ever start? You launch one from a coroutine scope using a coroutine builder.

A CoroutineScope is a boundary that owns and tracks coroutines. Its most important job is cancellation: when the scope is cancelled, every coroutine running inside it is cancelled too. This is what stops a network call from lingering after the screen it belonged to is gone.

A builder is the function you call on a scope to actually launch a coroutine. The two you will use are launch and async:

launch — "start this work and let it run." It returns a Job (a handle you can use to cancel or wait for it) but no result value. Use it for fire-and-forget work like load the messages and put them on screen.

scope.launch {
    val messages = fetchMessagesFromServer() // suspend call, allowed here
    showMessages(messages)
}

async — "start this work and give me back a result eventually." It returns a Deferred<T>, a promise of a future value that you retrieve by calling .await(). Use async when you want to run several things at the same time and combine their results:

suspend fun loadDashboard() = coroutineScope {
    val profile = async { fetchProfile() }   // both start...
    val channels = async { fetchChannels() } // ...concurrently
    render(profile.await(), channels.await()) // wait for both, then render
}

Because both async blocks start before either await(), the two network calls happen in parallel rather than one-after-the-other — a real speedup you will reach for often. The rule of thumb: launch when you do not need a result, async when you do and especially when you want concurrency.

Structured concurrency: the safety net

Here is the idea that makes Kotlin coroutines genuinely pleasant, and it has an intimidating name: structured concurrency.

The principle is simple. Every coroutine has a parent scope, and a scope does not consider itself "finished" until all the coroutines launched inside it have finished. Coroutines form a tree, and the tree cleans itself up. Three concrete benefits fall out of this:

Nothing gets lost. A coroutine cannot silently outlive the scope that started it. No leaked background work quietly running after you thought everything was done.

Cancellation flows down. Cancel a parent scope and all its children cancel automatically. When a Pulse user closes a screen, every in-flight request that screen started is cancelled in one move — no manual bookkeeping.

Errors flow up. If a child coroutine throws, the exception propagates to the parent instead of vanishing into the void. You get a real crash you can see and handle, not a mysterious silence.

Contrast that with the old callback world, where a background task could easily keep running after its screen was destroyed, updating a UI that no longer existed and leaking memory. Structured concurrency makes that class of bug largely disappear, and it is the reason you should almost never create free-floating coroutines with no proper parent.

The scope you will actually use: viewModelScope

In real Android apps you rarely create scopes by hand. The framework gives you ready-made ones tied to a lifecycle, and the one you will use constantly for networking is viewModelScope.

Every ViewModel comes with a built-in viewModelScope. Coroutines launched in it are automatically cancelled when the ViewModel is destroyed — which happens when the user permanently leaves the associated screen. This is exactly the behavior you want: if the user navigates away while a request is still loading, that request is cancelled for you, no leak, no crash trying to update a dead screen.

class ChannelViewModel : ViewModel() {

    fun loadMessages() {
        viewModelScope.launch {
            val messages = fetchMessagesFromServer()
            // update state so the UI can show the messages
        }
    }
}

This little pattern — viewModelScope.launch { ... } calling a suspend network function inside — is the shape of nearly every network call you will write in this book. Recognize it now; you are going to see it a hundred times.

Dispatchers: choosing the thread

We said suspend is about pausing, not about which thread. So how do we control the thread? With a CoroutineDispatcher. A dispatcher decides which thread (or pool of threads) a coroutine runs on. There are three you should know:

Dispatchers.Main — the Android main/UI thread. This is where you touch the UI. Coroutines launched from viewModelScope start here by default, which is convenient because it means you can update UI state directly without hopping threads yourself.

Dispatchers.IO — a pool of background threads optimized for I/O: input/output work that spends most of its time waiting, like network requests, reading files, or database queries. This is the dispatcher for networking.

Dispatchers.Default — a pool sized for CPU-heavy work: parsing a giant JSON payload, sorting a big list, image processing. Work that keeps the processor busy rather than waiting.

You switch dispatchers with withContext, which moves execution to a given dispatcher for the duration of a block and then automatically switches back:

suspend fun fetchMessagesFromServer(): List<Message> {
    return withContext(Dispatchers.IO) {
        // this runs on a background I/O thread
        // ... perform the network request ...
    }
}

Now here is a piece of very good news, and a rule to remember: Retrofit and Ktor already handle this for you. Both are designed to be main-safe — their suspend functions internally move the actual network work onto the right background threads, so you can call them from Dispatchers.Main (which is where viewModelScope starts) without freezing anything. This means that for the vast majority of your networking code, you will not write withContext(Dispatchers.IO) around your Retrofit or Ktor calls — the libraries take care of it.

So why teach dispatchers at all? Because you will still need withContext(Dispatchers.Default) for genuinely CPU-heavy post-processing of a response, and because understanding why you usually do not need to switch threads is far better than cargo-culting withContext(Dispatchers.IO) onto everything out of superstition, which is a habit you will see in a lot of copy-pasted code. Know the tool, and know that the good libraries have already used it for you.

When things go wrong: exception handling

Networks fail. Wi-Fi drops, servers return errors, requests time out. Real networking code is mostly about handling failure gracefully, so you need to know how exceptions behave in coroutines.

The wonderful part: because suspend code reads sequentially, you can wrap it in an ordinary try/catch, exactly as you would synchronous code.

fun loadMessages() {
    viewModelScope.launch {
        try {
            val messages = fetchMessagesFromServer()
            // show messages
        } catch (e: IOException) {
            // no connection, timeout — show a "check your connection" message
        } catch (e: Exception) {
            // something else went wrong — show a generic error
        }
    }
}

No callbacks, no error parameter threaded through five layers — just try/catch around code that looks synchronous. This readability is a large part of why coroutines won.

Two coroutine-specific wrinkles are worth flagging now, though we will treat error handling in real depth in Chapter 7:

Cancellation is a special exception. When a coroutine is cancelled (say, the user left the screen), it works by throwing a CancellationException. This is normal and healthy — it is how structured concurrency stops work. The trap: if you write a greedy catch (e: Exception) that swallows everything, you can accidentally swallow the cancellation too, and your coroutine will refuse to stop. So when you catch broadly, either catch the specific exceptions you expect (like IOException) or re-throw CancellationException. This is a subtle bug that bites almost every junior once; now you have been warned.

Sibling failures and supervisorScope. Under structured concurrency, if you launch several children in a normal scope and one fails, its failure cancels the siblings too. Sometimes that is right; sometimes you want the others to survive. supervisorScope gives you that "one child failing does not doom the rest" behavior. File it away — you will want it when loading several independent pieces of a screen at once.

From single values to streams: enter Flow

Everything so far returns one value: you ask for the messages, you get the messages, done. A suspend function is perfect for that "one request, one response" shape — which, you will recall, is exactly the shape of HTTP.

But some data is not a single value. It is a stream of values arriving over time. Think about Pulse's realtime side: new messages keep arriving, one after another, for as long as you are in the channel. Presence updates — who is online — keep changing. A typing indicator flickers on and off. There is no single "answer" to fetch; there is an ongoing flow of updates.

For that, Kotlin gives us Flow. A Flow<T> is a stream that emits multiple values of type T over time, asynchronously. If a suspend function is a function that eventually returns one value, a Flow is a stream that emits many values before it completes — or, in the case of realtime, one that keeps emitting indefinitely.

Here is a toy flow that emits a countdown, one number per second:

fun countdown(): Flow<Int> = flow {
    for (seconds in 5 downTo 1) {
        emit(seconds)      // push a value into the stream
        delay(1000)        // wait one second (suspends — doesn't block)
    }
}

Two things to notice. emit() pushes a value into the stream — a producer can emit as many times as it likes. And delay() is a suspending function (it pauses the coroutine without blocking a thread), which is why flows can space their emissions out over time so naturally.

Cold streams and collecting

A Flow is cold, which means the code inside it does not run until someone starts listening. The countdown() function above does nothing on its own — no counting happens — until a consumer collects it:

viewModelScope.launch {
    countdown().collect { seconds ->
        println("T-minus $seconds")   // runs once per emitted value
    }
}

collect is a suspend function that subscribes to the flow and runs your block for each value emitted. Because it suspends, the launching coroutine happily waits for values over time without blocking. When the flow finishes emitting (or the coroutine is cancelled), collection ends. Cold-and-collected is the default mental model for Flow: nothing happens until collection starts, and it stops when collection stops.

Transforming a flow

Flows come with operators that reshape the stream as values pass through, and they will look familiar if you have used map or filter on a list — except here they operate on values arriving over time:

messagesFlow()
    .filter { message -> !message.isFromBlockedUser }  // drop some values
    .map { message -> message.toUiModel() }            // transform each value
    .catch { e -> emit(emptyUiModel()) }               // handle errors in the stream
    .flowOn(Dispatchers.Default)                       // run upstream work off the main thread
    .collect { uiModel -> render(uiModel) }

map transforms each value, filter drops the ones you do not want, catch handles an exception raised upstream (the flow equivalent of try/catch), and flowOn controls which dispatcher the upstream work runs on. You do not need to memorize the full catalogue now; just absorb that a flow is a pipeline you can shape, and that these operators are how realtime data will be cleaned and reshaped before it reaches the screen.

StateFlow and SharedFlow: flows for the UI

Two special flows show up so often in Android that they deserve an early introduction, because you will use both to connect your networking layer to your screens.

StateFlow is a flow that always holds exactly one current value — the latest state — and emits that current value immediately to any new collector. That makes it the natural way to hold UI state. A Pulse channel screen might expose a StateFlow<ChannelUiState> describing whether it is currently loading, showing messages, or showing an error:

class ChannelViewModel : ViewModel() {

    private val _uiState = MutableStateFlow<ChannelUiState>(ChannelUiState.Loading)
    val uiState: StateFlow<ChannelUiState> = _uiState  // read-only view for the UI

    fun loadMessages() {
        viewModelScope.launch {
            _uiState.value = ChannelUiState.Loading
            try {
                val messages = fetchMessagesFromServer()
                _uiState.value = ChannelUiState.Success(messages)
            } catch (e: Exception) {
                _uiState.value = ChannelUiState.Error(e.message)
            }
        }
    }
}

The _uiState / uiState pair is a convention worth adopting immediately: a private mutable MutableStateFlow that the ViewModel writes to, exposed publicly as a read-only StateFlow the UI can only observe. The UI watches uiState and redraws whenever it changes — loading spinner, then messages, then maybe an error — all driven by that one always-current value. This exact pattern is how nearly every screen in this book will consume network results.

SharedFlow is its sibling, used for one-off events rather than persistent state — things like "show a toast," "navigate to the next screen," or "a new message just arrived." Unlike StateFlow, it does not hold a single current value and does not replay the last one to new collectors by default, which is exactly what you want for events that should fire once and not re-fire when the screen rotates. When we get to realtime, incoming messages will naturally flow through a SharedFlow.

For now, the one-line summary: StateFlow for "what is the current state," SharedFlow for "something just happened."

Collecting flows safely on Android

One Android-specific caution, because it is a real source of bugs. When a screen collects a flow, that collection should stop while the screen is in the background and resume when it comes back — otherwise you keep receiving and processing updates for a screen nobody is looking at, wasting battery and occasionally crashing.

If you are on Jetpack Compose, the tool is collectAsStateWithLifecycle(), which turns a StateFlow into Compose state while respecting the lifecycle automatically:

@Composable
fun ChannelScreen(viewModel: ChannelViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        is ChannelUiState.Loading -> LoadingSpinner()
        is ChannelUiState.Success -> MessageList(state.messages)
        is ChannelUiState.Error   -> ErrorMessage(state.message)
    }
}

That single collectAsStateWithLifecycle() call reads the current UI state, redraws whenever it changes, and — crucially — pauses collection when the screen is not visible. On the older View system, the equivalent is collecting inside repeatOnLifecycle(Lifecycle.State.STARTED). Either way, the principle is the same: collect flows in a lifecycle-aware way, so you are not doing work for a screen that has gone off-stage. This becomes especially important with realtime flows that never stop emitting on their own.

Putting it together: a preview of the shape

Let's assemble the pieces into the exact shape you will flesh out with real networking in the coming chapters. We have a ViewModel that launches a coroutine in its lifecycle-aware scope, calls a suspend function to fetch data off the main thread, catches failures with plain try/catch, and pushes results into a StateFlow the UI observes:

class ChannelViewModel(
    private val repository: MessageRepository  // hides the networking details
) : ViewModel() {

    private val _uiState = MutableStateFlow<ChannelUiState>(ChannelUiState.Loading)
    val uiState: StateFlow<ChannelUiState> = _uiState

    fun loadMessages(channelId: Long) {
        viewModelScope.launch {                       // structured, lifecycle-aware coroutine
            _uiState.value = ChannelUiState.Loading
            try {
                val messages = repository.getMessages(channelId)  // suspend, main-safe
                _uiState.value = ChannelUiState.Success(messages)
            } catch (e: Exception) {
                _uiState.value = ChannelUiState.Error(e.message)
            }
        }
    }
}

Every idea from this chapter is in those few lines: viewModelScope and structured concurrency, a suspend call that runs off the main thread, plain try/catch error handling, and a StateFlow carrying state to the UI. The only thing missing is the actual HTTP request inside repository.getMessages() — and filling that in with Retrofit is precisely what the next chapter does. When we get there, getMessages will simply be a Retrofit suspend function, and it will slot into this shape without changing anything around it. That is not a coincidence; it is why we built the shape first.

What you learned

Because Android forbids networking on the main thread, all networking is asynchronous, and Kotlin's tool for async is the coroutine — a unit of work that can suspend (pause and free its thread) and later resume, letting you write async code that reads top-to-bottom.

You mark pausable functions with suspend, and you can only call them from another suspend function or from inside a coroutine. You start coroutines from a CoroutineScope using launch (fire-and-forget) or async (for a result, and for concurrency via await). Structured concurrency ties coroutines into a tree so that cancellation flows down and errors flow up, preventing the leaks that plagued the callback era. On Android you will lean on viewModelScope, which cancels its coroutines automatically when the screen is gone.

Dispatchers (Main, IO, Default) decide which thread runs the work — but Retrofit and Ktor are main-safe, so you will rarely switch dispatchers for the network call itself. Failures are handled with ordinary try/catch, with one caution: do not accidentally swallow CancellationException.

For data that arrives as a stream over time — the heart of realtime — you use Flow, a cold, collectable pipeline you can shape with operators like map, filter, and catch. Two flows dominate Android UI work: StateFlow for current state and SharedFlow for one-off events, and you should always collect them in a lifecycle-aware way (collectAsStateWithLifecycle on Compose, repeatOnLifecycle on Views).

You now have both halves of the foundation: Chapter 1 gave you HTTP, and this chapter gave you the async machinery to run it without freezing the UI. It is finally time to combine them. In the next chapter we make our very first real network call — fetching Pulse's channels from a live server — using Retrofit.

Exercises

  1. Explain, in your own words, the difference between blocking a thread and suspending a coroutine. Why does the distinction matter for the Android main thread specifically?
  2. Given suspend fun fetchProfile(): Profile and suspend fun fetchChannels(): List<Channel>, write a suspend function that fetches both concurrently and returns them as a Pair. Which builder do you need?
  3. Why can you call a suspend function from inside viewModelScope.launch { } but not from a plain, non-suspend function like onClick() directly? What is the smallest change that makes the call legal?
  4. A teammate wraps every Retrofit call in withContext(Dispatchers.IO). Explain why this is usually unnecessary, and name a case where switching to Dispatchers.Default would be appropriate.
  5. Describe when you would model a piece of data as a suspend function returning a single value versus as a Flow. Give one Pulse example of each.
  6. A junior writes catch (e: Exception) { showError() } around a coroutine and reports that "cancelling the screen doesn't stop the request." What is happening, and how do you fix it?