Kotlin Coroutines
Write asynchronous, non-blocking code with suspend functions, scopes, dispatchers, and async/await
suspend
Scope
Dispatchers
async
1Coroutines Basics
Coroutines are lightweight threads for asynchronous programming. They let you write sequential-looking code that runs non-blocking work — network calls, database queries, file I/O — without blocking the main thread. Coroutines are the recommended approach for async code in Kotlin and Android.
Why coroutines over threads?
| Feature | Threads | Coroutines |
| Cost | Heavy (~1 MB stack each) | Very lightweight — thousands possible |
| Creation | Expensive | Cheap — suspend/resume |
| Blocking | Blocks OS thread | Suspends without blocking thread |
| Cancellation | Manual, error-prone | Structured — auto propagates |
| Code style | Callbacks, CompletableFuture | Sequential suspend functions |
Your first coroutine
runBlocking and launchimport kotlinx.coroutines.*
fun main() = runBlocking {
println("Main starts")
launch {
delay(1000L)
println("Coroutine finished")
}
println("Main continues")
}
// Output order:
// Main starts
// Main continues
// Coroutine finished (after ~1 second)
Key building blocks
suspend
Pausable functions
CoroutineScope
Lifecycle boundary
Dispatcher
Which thread pool
Gradle dependency// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
// Android: also use lifecycle-viewmodel-ktx, etc.
}
Structured concurrency
Coroutines are organized in scopes — child coroutines cannot outlive their parent. This prevents leaks and makes cancellation predictable.
2Suspend Functions
A suspend function can pause execution without blocking the thread, then resume later. Mark functions with the suspend keyword. They can only be called from other suspend functions or inside a coroutine.
Defining suspend functionsimport kotlinx.coroutines.*
suspend fun fetchUser(): String {
delay(500) // simulates network delay — non-blocking
return "Nikhil"
}
suspend fun fetchPosts(): List<String> {
delay(300)
return listOf("Post 1", "Post 2")
}
fun main() = runBlocking {
val user = fetchUser()
val posts = fetchPosts()
println("$user has ${posts.size} posts")
}
Sequential vs parallel suspend calls
Sequential — one after anotherimport kotlinx.coroutines.*
suspend fun loadDataSequential() {
val user = fetchUser() // waits ~500ms
val posts = fetchPosts() // then waits ~300ms
// Total: ~800ms
}
// Parallel loading uses async — see Async and Await section
suspend rules
| Rule | Explanation |
suspend keyword required | Compiler transforms function for coroutine machinery |
| Call from coroutine only | Regular functions cannot call suspend directly |
| Can call other suspend functions | Natural chaining of async operations |
| Non-blocking | Thread is free while suspended |
Bridging coroutine to regular codeimport kotlinx.coroutines.*
fun startLoading() {
// Launch coroutine from non-suspend context
GlobalScope.launch {
val data = fetchUser()
println(data)
}
}
// Android: viewModelScope.launch { } or lifecycleScope.launch { }
delay vs Thread.sleep: delay() is suspend — it releases the thread. Thread.sleep() blocks the thread — avoid in coroutines.
3Coroutine Scope
A CoroutineScope defines the lifecycle of coroutines launched within it. When the scope is cancelled, all its child coroutines are cancelled too — this is structured concurrency.
Creating a scopeimport kotlinx.coroutines.*
class DataLoader {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun load() {
scope.launch {
val data = fetchUser()
println(data)
}
}
fun cleanup() {
scope.cancel() // cancels all running coroutines in scope
}
}
launch vs async
| Builder | Returns | Use when |
launch { } | Job (no result) | Fire-and-forget, UI updates, logging |
async { } | Deferred<T> | Need a computed result — use await() |
runBlocking { } | Blocks caller thread | Tests, main(), bridging only |
launch exampleimport kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(5) { i ->
println("Tick $i")
delay(200L)
}
}
delay(500L)
job.cancel()
job.join()
println("Cancelled")
}
Android scopes
| Scope | Lifecycle |
viewModelScope | Cleared when ViewModel is destroyed |
lifecycleScope | Activity/Fragment lifecycle |
rememberCoroutineScope() | Jetpack Compose composable |
GlobalScope | App-wide — avoid in production |
Android ViewModel pattern (concept)// class UserViewModel : ViewModel() {
// fun loadUser() {
// viewModelScope.launch {
// val user = repository.getUser()
// _uiState.value = UiState.Success(user)
// }
// }
// }
SupervisorJob
Use SupervisorJob() when one child failure should not cancel siblings — common in ViewModels loading multiple independent resources.
4Dispatchers
Dispatchers determine which thread pool runs coroutine code. Switch dispatchers with withContext(dispatcher) to run work on the appropriate thread.
| Dispatcher | Thread pool | Use for |
Dispatchers.Main | UI thread (Android main) | Updating UI, Compose state |
Dispatchers.IO | Shared pool (~64 threads) | Network, disk I/O, database |
Dispatchers.Default | CPU cores count | Sorting, parsing, heavy computation |
Dispatchers.Unconfined | Starts on caller, resumes anywhere | Testing only — avoid in production |
withContext — switch dispatcherimport kotlinx.coroutines.*
suspend fun loadUserFromNetwork(): String {
return withContext(Dispatchers.IO) {
delay(500) // network call simulation
"User data from API"
}
}
suspend fun processData(raw: String): String {
return withContext(Dispatchers.Default) {
raw.uppercase().reversed() // CPU work
}
}
fun main() = runBlocking {
val raw = loadUserFromNetwork()
val processed = processData(raw)
println(processed)
}
launch with dispatcher
Specify dispatcher in launchimport kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.IO) {
val data = fetchFromDatabase()
withContext(Dispatchers.Main) {
updateUI(data) // switch to Main for UI
}
}
}
suspend fun fetchFromDatabase(): String {
delay(200)
return "DB result"
}
fun updateUI(data: String) {
println("UI updated: $data")
}
Dispatcher decision guide
Main
Touch UI, show Toast, update TextView or Compose State.
IO
Retrofit API calls, Room database, file read/write.
Default
JSON parsing, image processing, list sorting.
Rule of thumb: Do heavy work off Main; return to Main only for UI updates. Never block Main with Thread.sleep or synchronous network calls.
5Async and Await
async starts a coroutine that returns a Deferred<T> — a future result. Call await() to get the value (suspending until ready). Use async for parallel work when you need multiple results.
Basic async/awaitimport kotlinx.coroutines.*
suspend fun fetchUser(): String {
delay(500)
return "Nikhil"
}
suspend fun fetchPosts(): List<String> {
delay(300)
return listOf("Post A", "Post B")
}
fun main() = runBlocking {
val userDeferred = async { fetchUser() }
val postsDeferred = async { fetchPosts() }
val user = userDeferred.await()
val posts = postsDeferred.await()
println("$user — ${posts.size} posts")
}
Parallel vs sequential timing
Parallel loading — ~500ms totalimport kotlinx.coroutines.*
fun main() = runBlocking {
val time = measureTimeMillis {
val user = async { fetchUser() } // starts immediately
val posts = async { fetchPosts() } // starts immediately
println("${user.await()} — ${posts.await().size} posts")
}
println("Completed in ${time}ms") // ~500ms (max of both), not 800ms
}
suspend fun fetchUser(): String { delay(500); return "User" }
suspend fun fetchPosts(): List<String> { delay(300); return listOf("P1") }
async with dispatchers
Parallel IO operationsimport kotlinx.coroutines.*
fun main() = runBlocking {
val profile = async(Dispatchers.IO) { fetchProfile() }
val settings = async(Dispatchers.IO) { fetchSettings() }
val result = ProfileResult(
profile = profile.await(),
settings = settings.await()
)
println(result)
}
data class ProfileResult(val profile: String, val settings: String)
suspend fun fetchProfile(): String { delay(400); return "Profile" }
suspend fun fetchSettings(): String { delay(300); return "Settings" }
launch vs async comparison
| Aspect | launch | async |
| Return type | Job | Deferred<T> |
| Get result | No return value | .await() |
| Exception | Propagates immediately | Held until await() |
| Best for | Side effects | Parallel computation with results |
Error handling with async
try-catch around awaitimport kotlinx.coroutines.*
suspend fun riskyFetch(): String {
delay(100)
throw IOException("Network failed")
}
fun main() = runBlocking {
val deferred = async(Dispatchers.IO) { riskyFetch() }
try {
val result = deferred.await()
println(result)
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
When NOT to use async
If operations are independent but you don't need parallel speedup, sequential suspend calls are simpler. Use async when wall-clock time matters — e.g. loading dashboard data from multiple APIs at once.
6Summary Cheatsheet
| Topic | Key Takeaway |
| Coroutines Basics | Lightweight async; launch, runBlocking; structured concurrency |
| Suspend Functions | suspend fun; pause without blocking; call from coroutine |
| Coroutine Scope | CoroutineScope; cancel scope cancels children; viewModelScope on Android |
| Dispatchers | Main = UI; IO = network/disk; Default = CPU; withContext |
| Async and Await | async { } → Deferred<T>; await() for parallel results |
| Library | kotlinx-coroutines-core |
| Next lesson | Android Development |