PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 5 min read

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.

  1. The Basics (this post)
  2. Structured Concurrency
  3. 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:

  • runBlocking creates a coroutine scope and blocks the current thread until everything inside it completes. You’ll use this in main() functions and tests. Don’t use it in production Android or server code — it defeats the purpose.
  • launch starts 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 from Thread.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:

DispatcherThread poolUse case
Dispatchers.MainMain/UI threadUI updates (Android)
Dispatchers.IOShared pool, up to 64 threadsNetwork calls, file I/O, database queries
Dispatchers.DefaultShared pool, sized to CPU coresCPU-heavy work — sorting, parsing, calculations
Dispatchers.UnconfinedNo specific threadTesting 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.