Kotlin Coroutines — Part 1: The Basics
A practical introduction to Kotlin coroutines — why they exist, how to launch them, suspend functions, dispatchers, and concurrent execution patterns.
This is Part 1 of a series on Kotlin Coroutines.
- The Basics (this post)
- Structured Concurrency
- Real-World Patterns
Why coroutines
You need to make a network call. You can’t do it on the main thread — it’ll freeze the UI (Android) or block the request thread (Spring Boot). So you reach for threads.
import kotlin.concurrent.thread
fun main() {
println("Start")
thread {
Thread.sleep(1000)
println("Done on ${Thread.currentThread().name}")
}
println("Main continues")
Thread.sleep(2000) // wait for the thread to finish
}
Start
Main continues
Done on Thread-0
That works. But threads are expensive. Each one allocates about 1 MB of stack memory. Spin up 10,000 threads and you’ll run out of memory. Try it:
import kotlin.concurrent.thread
fun main() {
val threads = List(100_000) {
thread {
Thread.sleep(5000)
}
}
threads.forEach { it.join() }
println("Done")
}
This will either crawl or crash with OutOfMemoryError. Now try the same thing with coroutines:
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val jobs = List(100_000) {
launch {
delay(5000)
}
}
jobs.forEach { it.join() }
println("Done")
}
This runs fine. 100,000 coroutines, no problem. Coroutines are lightweight — they don’t map 1:1 to threads. They’re suspended and resumed on a shared thread pool, using a fraction of the memory.
And then there’s the callback problem. Before coroutines, async code in Android looked like this:
fetchUser(userId, callback = { user ->
fetchOrders(user.id, callback = { orders ->
fetchOrderDetails(orders[0].id, callback = { details ->
// callback hell
})
})
})
Coroutines let you write the same logic as sequential code:
val user = fetchUser(userId)
val orders = fetchOrders(user.id)
val details = fetchOrderDetails(orders[0].id)
Same async behavior. Readable code. No nesting.
Your first coroutine
Three things you need to know right away: runBlocking, launch, and delay.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("Before launch")
launch {
delay(1000)
println("Inside coroutine")
}
println("After launch")
}
Before launch
After launch
Inside coroutine
What’s happening here:
runBlockingcreates a coroutine scope and blocks the current thread until everything inside it completes. You’ll use this inmain()functions and tests. Don’t use it in production Android or server code — it defeats the purpose.launchstarts a new coroutine. It doesn’t block — it fires off the coroutine and moves on immediately.delay(1000)suspends the coroutine for 1 second without blocking the thread. This is the key difference fromThread.sleep()— the thread is free to do other work.
delay vs Thread.sleep
This matters. Thread.sleep() blocks the thread — nothing else can run on it. delay() suspends the coroutine — the thread goes back to the pool and picks up other work.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch {
delay(200)
println("Coroutine 1")
}
launch {
delay(100)
println("Coroutine 2")
}
println("Main")
}
Main
Coroutine 2
Coroutine 1
Both coroutines run on the same thread, interleaving their execution. If you replaced delay() with Thread.sleep(), the second coroutine would have to wait for the first one to finish sleeping.
Suspend functions
A suspend function is a function that can be paused and resumed. It can call other suspend functions like delay(), and it can only be called from a coroutine or another suspend function.
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
suspend fun fetchUser(userId: String): String {
delay(1000) // simulate network call
return "User($userId)"
}
suspend fun fetchOrders(userId: String): List<String> {
delay(800) // simulate network call
return listOf("Order-1", "Order-2")
}
fun main() = runBlocking {
val user = fetchUser("42")
println(user)
val orders = fetchOrders("42")
println(orders)
}
User(42)
[Order-1, Order-2]
The suspend keyword tells the compiler this function may pause. Under the hood, the compiler transforms it into a state machine with callbacks — but you never see that. You just write sequential code.
A few rules:
- You can’t call a suspend function from a regular function. The compiler will stop you.
- You can call regular functions from a suspend function — no restrictions there.
- Suspend functions don’t automatically run on a background thread. They run on whatever dispatcher the calling coroutine uses. More on that in the Dispatchers section.
launch vs async
launch fires off a coroutine and returns a Job. It’s fire-and-forget — you don’t get a result back.
async fires off a coroutine and returns a Deferred<T>. You call .await() on it to get the result.
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
// launch — fire and forget
val job = launch {
delay(500)
println("launch completed")
}
// async — returns a result
val deferred = async {
delay(500)
"Hello from async"
}
job.join() // wait for launch to finish
val result = deferred.await() // wait for async and get result
println(result)
}
launch completed
Hello from async
Use launch when you want to do something but don’t need a return value — logging, updating UI, sending analytics. Use async when you need the result of a computation.
Think of it this way: launch is like calling a void function. async is like calling a function that returns something.
Dispatchers
Dispatchers decide which thread (or thread pool) a coroutine runs on. There are four built-in options:
| Dispatcher | Thread pool | Use case |
|---|---|---|
Dispatchers.Main | Main/UI thread | UI updates (Android) |
Dispatchers.IO | Shared pool, up to 64 threads | Network calls, file I/O, database queries |
Dispatchers.Default | Shared pool, sized to CPU cores | CPU-heavy work — sorting, parsing, calculations |
Dispatchers.Unconfined | No specific thread | Testing and special cases. Avoid in production. |
Switching dispatchers with withContext
You switch dispatchers using withContext. It suspends the current coroutine, runs the block on the specified dispatcher, and resumes back on the original dispatcher when done.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
suspend fun fetchFromNetwork(): String {
return withContext(Dispatchers.IO) {
println("Fetching on ${Thread.currentThread().name}")
Thread.sleep(1000) // simulating a blocking network call
"response data"
}
}
suspend fun parseResponse(data: String): Map<String, String> {
return withContext(Dispatchers.Default) {
println("Parsing on ${Thread.currentThread().name}")
mapOf("key" to data) // simulate CPU work
}
}
fun main() = runBlocking {
val data = fetchFromNetwork()
val parsed = parseResponse(data)
println("Result: $parsed")
}
Fetching on DefaultDispatcher-worker-1
Parsing on DefaultDispatcher-worker-1
Result: {key=response data}
In Android, you’d use this pattern to switch to Dispatchers.IO for network calls and then back to Dispatchers.Main to update the UI. In Spring Boot, Dispatchers.IO and Dispatchers.Default are your main ones — there’s no main thread to worry about.
When to use which
- Making a Retrofit/OkHttp call?
Dispatchers.IO - Reading a file?
Dispatchers.IO - Running a database query?
Dispatchers.IO - Parsing a large JSON?
Dispatchers.Default - Sorting a large list?
Dispatchers.Default - Updating a TextView?
Dispatchers.Main
Sequential vs concurrent execution
This is where coroutines really shine. Say you need data from two API calls. By default, suspend functions run sequentially:
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
suspend fun fetchUserProfile(): String {
delay(1000) // 1 second
return "UserProfile"
}
suspend fun fetchUserOrders(): List<String> {
delay(1000) // 1 second
return listOf("Order-1", "Order-2")
}
fun main() = runBlocking {
val time = measureTimeMillis {
val profile = fetchUserProfile()
val orders = fetchUserOrders()
println("$profile, $orders")
}
println("Sequential: ${time}ms")
}
UserProfile, [Order-1, Order-2]
Sequential: 2012ms
Two seconds. They ran one after another. But these calls don’t depend on each other — they can run at the same time.
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
suspend fun fetchUserProfile(): String {
delay(1000)
return "UserProfile"
}
suspend fun fetchUserOrders(): List<String> {
delay(1000)
return listOf("Order-1", "Order-2")
}
fun main() = runBlocking {
val time = measureTimeMillis {
val profile = async { fetchUserProfile() }
val orders = async { fetchUserOrders() }
println("${profile.await()}, ${orders.await()}")
}
println("Concurrent: ${time}ms")
}
UserProfile, [Order-1, Order-2]
Concurrent: 1017ms
One second. Both calls ran concurrently. The total time is the duration of the slowest call, not the sum. That’s a 2x speedup with two lines changed.
A real example
Let’s put it together. Fetch a user profile and their orders concurrently, combine the results into a single response.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
data class UserProfile(
val id: String,
val name: String,
val email: String
)
data class Order(
val id: String,
val product: String,
val amount: Double
)
data class DashboardData(
val profile: UserProfile,
val orders: List<Order>,
val totalSpent: Double
)
suspend fun fetchProfile(userId: String): UserProfile {
return withContext(Dispatchers.IO) {
delay(1000) // simulate API call
UserProfile(userId, "Navkrishna", "[email protected]")
}
}
suspend fun fetchOrders(userId: String): List<Order> {
return withContext(Dispatchers.IO) {
delay(800) // simulate API call
listOf(
Order("ord_1", "Kotlin in Action", 39.99),
Order("ord_2", "Coroutines Deep Dive", 29.99),
Order("ord_3", "IntelliJ License", 149.99)
)
}
}
suspend fun loadDashboard(userId: String): DashboardData {
return withContext(Dispatchers.IO) {
val profileDeferred = async { fetchProfile(userId) }
val ordersDeferred = async { fetchOrders(userId) }
val profile = profileDeferred.await()
val orders = ordersDeferred.await()
val totalSpent = orders.sumOf { it.amount }
DashboardData(profile, orders, totalSpent)
}
}
fun main() = runBlocking {
val dashboard = loadDashboard("usr_42")
println("Welcome, ${dashboard.profile.name}")
println("Email: ${dashboard.profile.email}")
println("Orders:")
for (order in dashboard.orders) {
println(" - ${order.product}: $${order.amount}")
}
println("Total spent: $${dashboard.totalSpent}")
}
Welcome, Navkrishna
Email: [email protected]
Orders:
- Kotlin in Action: $39.99
- Coroutines Deep Dive: $29.99
- IntelliJ License: $149.99
Total spent: $219.97
Both API calls run concurrently inside loadDashboard. The function takes ~1 second (the slower call) instead of ~1.8 seconds (both calls added up). On Android, you’d call loadDashboard from a ViewModel with viewModelScope.launch. On Spring Boot, you’d call it from a controller using a suspend function or runBlocking in a blocking controller.
Common mistakes
Blocking inside a coroutine
// DON'T do this
launch {
Thread.sleep(1000) // blocks the thread, defeats the purpose
}
// DO this
launch {
delay(1000) // suspends the coroutine, thread is free
}
If you must call blocking code (a library that doesn’t support coroutines), wrap it in withContext(Dispatchers.IO) so it blocks an IO thread instead of the main thread or the default pool.
Forgetting to await
// BUG: result is a Deferred, not the actual value
val result = async { fetchData() }
println(result) // prints "DeferredCoroutine{Active}@..."
// FIX: call await()
val result = async { fetchData() }
println(result.await()) // prints the actual data
Using GlobalScope
// DON'T do this
import kotlinx.coroutines.GlobalScope
GlobalScope.launch {
// this coroutine lives forever — no parent, no cancellation, no structure
}
GlobalScope creates a coroutine that isn’t tied to any lifecycle. It won’t be cancelled when your Activity is destroyed or your request completes. It’s a memory leak waiting to happen. Always use a structured scope — viewModelScope on Android, coroutineScope in suspend functions, or a custom scope tied to your component’s lifecycle.
We’ll cover structured concurrency properly in Part 2.
Catching exceptions the wrong way
// This won't catch the exception
try {
launch {
throw RuntimeException("boom")
}
} catch (e: RuntimeException) {
// never reached — launch propagates to the parent scope
}
Exception handling in coroutines is different from regular try/catch. launch propagates exceptions to the parent scope. async stores them and rethrows when you call await(). We’ll cover this in detail in Part 2.
What’s next
In Part 2, we’ll cover structured concurrency — the mechanism that makes coroutines safe and predictable. You’ll learn about coroutine scopes, parent-child relationships, cancellation, supervisorScope, and proper exception handling. It’s the difference between coroutines that work and coroutines that work correctly.