You know that spinning wheel of death when your Android app freezes? That "Application Not Responding" dialog that makes users uninstall your app faster than you can say "oops"?
Yeah, that's what happens when you block the main thread.
Network calls, database queries, image processing - do any of these on the main thread and Android will punish you. The UI freezes, users rage, and your app rating drops faster than WiFi at a Kenyan matatu stage.
Enter Kotlin coroutines - the elegant way to handle asynchronous operations without callback hell, without RxJava complexity, and without losing your sanity. Whether you're building REST APIs with Spring Boot and Kotlin or native Android apps, coroutines are essential for modern Kotlin development.
Think of coroutines like a matatu conductor who can multitask: collecting fares while keeping track of stops, all without making passengers wait. The main thread is the driver - focused on keeping the UI smooth. Coroutines are the conductor - handling everything else without blocking the ride.
What Are Coroutines (In Plain English)?
Coroutines are lightweight threads that let you write asynchronous code as if it were synchronous. No callbacks. No promise chains. Just clean, readable code.
Traditional approach (callback hell):
// ❌ Callback hell
fun loadUserData() {
fetchUserFromNetwork(userId) { user ->
fetchUserPosts(user.id) { posts ->
fetchPostComments(posts[0].id) { comments ->
updateUI(user, posts, comments) // Finally!
}
}
}
}
With coroutines:
// ✅ Clean, sequential code
suspend fun loadUserData() {
val user = fetchUserFromNetwork(userId)
val posts = fetchUserPosts(user.id)
val comments = fetchPostComments(posts[0].id)
updateUI(user, posts, comments)
}
Same logic, infinitely more readable.
Setup: Add Coroutines to Your Android Project
build.gradle.kts (app level):
dependencies {
// Kotlin Coroutines (version 1.10.1 as of 2025)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
// Lifecycle-aware coroutines for Android
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
}
Sync your project and you're ready to roll.
Core Concepts: The Building Blocks
1. Suspend Functions
Functions marked with suspend can be paused and resumed without blocking the thread.
// Regular function - can't call suspend functions
fun regularFunction() {
// This won't compile:
// val data = fetchData() // Error!
}
// Suspend function - can call other suspend functions
suspend fun fetchData(): String {
delay(1000) // Suspends for 1 second (doesn't block thread!)
return "Data loaded"
}
suspend fun processData() {
val data = fetchData() // ✅ This works
println(data)
}
Key point: suspend functions can only be called from coroutines or other suspend functions.
2. Coroutine Builders
Launch coroutines using these builders:
import kotlinx.coroutines.*
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Launch a coroutine
lifecycleScope.launch {
// This code runs in a coroutine
val result = fetchDataFromNetwork()
updateUI(result)
}
}
private suspend fun fetchDataFromNetwork(): String {
delay(2000) // Simulates network call
return "Hello from the network!"
}
private fun updateUI(data: String) {
textView.text = data
}
}
Coroutine builders:
launch {}- Fire and forget (doesn't return a result)async {}- Returns a result viaDeferred<T>runBlocking {}- Blocks the current thread (avoid in Android!)
3. Dispatchers: Where Your Code Runs
Dispatchers control which thread executes your coroutine.
import kotlinx.coroutines.*
lifecycleScope.launch {
// Dispatchers.Main - UI thread (default for lifecycleScope)
textView.text = "Loading..."
val data = withContext(Dispatchers.IO) {
// Dispatchers.IO - Network/disk operations
fetchFromDatabase()
}
// Back to Dispatchers.Main automatically
textView.text = data
val processed = withContext(Dispatchers.Default) {
// Dispatchers.Default - CPU-intensive work
processLargeDataset(data)
}
textView.text = processed
}
Dispatcher Guide:
- Dispatchers.Main: UI operations (updating views, showing toasts)
- Dispatchers.IO: Network requests, database queries, file operations
- Dispatchers.Default: Heavy computations, sorting, parsing large JSON
Real-World Example: Fetching User Profile
Let's build a complete example with Retrofit, Room database, and proper error handling.
1. Define API service:
// UserApi.kt
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: Int): User
@GET("users/{id}/posts")
suspend fun getUserPosts(@Path("id") userId: Int): List<Post>
}
data class User(
val id: Int,
val name: String,
val email: String,
val avatarUrl: String
)
data class Post(
val id: Int,
val title: String,
val content: String
)
2. Create ViewModel with coroutines:
// UserViewModel.kt
class UserViewModel(
private val api: UserApi,
private val userDao: UserDao
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadUserProfile(userId: Int) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
// Parallel network calls using async
val userDeferred = async { api.getUser(userId) }
val postsDeferred = async { api.getUserPosts(userId) }
// Wait for both to complete
val user = userDeferred.await()
val posts = postsDeferred.await()
// Save to database (cache)
withContext(Dispatchers.IO) {
userDao.insertUser(user)
}
// Update UI
_uiState.value = UiState.Success(user, posts)
} catch (e: IOException) {
// Network error - try loading from cache
val cachedUser = withContext(Dispatchers.IO) {
userDao.getUserById(userId)
}
if (cachedUser != null) {
_uiState.value = UiState.Success(cachedUser, emptyList())
} else {
_uiState.value = UiState.Error("No internet connection")
}
} catch (e: HttpException) {
_uiState.value = UiState.Error("Server error: ${e.code()}")
} catch (e: Exception) {
_uiState.value = UiState.Error("Something went wrong: ${e.message}")
}
}
}
sealed class UiState {
object Loading : UiState()
data class Success(val user: User, val posts: List<Post>) : UiState()
data class Error(val message: String) : UiState()
}
}
3. Use in Activity/Fragment:
// UserProfileActivity.kt
class UserProfileActivity : AppCompatActivity() {
private val viewModel: UserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user_profile)
val userId = intent.getIntExtra("USER_ID", 1)
// Collect state updates
lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is UserViewModel.UiState.Loading -> {
progressBar.visibility = View.VISIBLE
errorView.visibility = View.GONE
contentView.visibility = View.GONE
}
is UserViewModel.UiState.Success -> {
progressBar.visibility = View.GONE
errorView.visibility = View.GONE
contentView.visibility = View.VISIBLE
// Update UI
nameTextView.text = state.user.name
emailTextView.text = state.user.email
Glide.with(this@UserProfileActivity)
.load(state.user.avatarUrl)
.into(avatarImageView)
postsAdapter.submitList(state.posts)
}
is UserViewModel.UiState.Error -> {
progressBar.visibility = View.GONE
errorView.visibility = View.VISIBLE
contentView.visibility = View.GONE
errorTextView.text = state.message
}
}
}
}
// Load data
viewModel.loadUserProfile(userId)
// Retry button
retryButton.setOnClickListener {
viewModel.loadUserProfile(userId)
}
}
}
Advanced Patterns
1. Debouncing Search Input
// SearchViewModel.kt
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val _searchResults = MutableStateFlow<List<SearchResult>>(emptyList())
val searchResults: StateFlow<List<SearchResult>> = _searchResults.asStateFlow()
init {
// Debounce search queries (wait 300ms after user stops typing)
viewModelScope.launch {
searchQuery
.debounce(300)
.filter { it.isNotBlank() }
.distinctUntilChanged()
.collectLatest { query ->
searchProducts(query)
}
}
}
fun onSearchQueryChanged(query: String) {
searchQuery.value = query
}
private suspend fun searchProducts(query: String) {
try {
val results = repository.searchProducts(query)
_searchResults.value = results
} catch (e: Exception) {
_searchResults.value = emptyList()
}
}
}
2. Parallel API Calls with async
suspend fun loadDashboardData(): DashboardData {
// Launch multiple API calls in parallel
return coroutineScope {
val userDeferred = async { api.getUser() }
val statsDeferred = async { api.getStats() }
val notificationsDeferred = async { api.getNotifications() }
// Wait for all results
DashboardData(
user = userDeferred.await(),
stats = statsDeferred.await(),
notifications = notificationsDeferred.await()
)
}
}
This is way faster than sequential calls!
Sequential: 3 requests × 500ms each = 1.5 seconds total Parallel: max(500ms, 500ms, 500ms) = 500ms total
3. Timeout Handling
suspend fun fetchDataWithTimeout(): String {
return withTimeout(5000) { // 5 second timeout
api.fetchData()
}
}
// Or use withTimeoutOrNull to avoid throwing exception
suspend fun fetchDataSafely(): String? {
return withTimeoutOrNull(5000) {
api.fetchData()
}
}
4. Periodic Updates (Polling)
class LiveScoreViewModel(private val api: ScoreApi) : ViewModel() {
private val _score = MutableStateFlow<Score?>(null)
val score: StateFlow<Score?> = _score.asStateFlow()
fun startPolling(matchId: Int) {
viewModelScope.launch {
while (isActive) { // Runs until coroutine is cancelled
try {
val currentScore = api.getScore(matchId)
_score.value = currentScore
} catch (e: Exception) {
// Handle error
}
delay(10_000) // Poll every 10 seconds
}
}
}
}
Common Mistakes and How to Avoid Them
1. Blocking the main thread:
// ❌ DON'T - This blocks the UI thread
lifecycleScope.launch(Dispatchers.Main) {
val data = api.fetchData() // Network call on main thread!
}
// ✅ DO - Use appropriate dispatcher
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) {
api.fetchData()
}
}
2. Not handling cancellation:
// ❌ DON'T - Job leaks if activity is destroyed
GlobalScope.launch {
val data = api.fetchData()
updateUI(data) // Activity might be destroyed!
}
// ✅ DO - Use lifecycle-aware scopes
lifecycleScope.launch {
val data = api.fetchData()
updateUI(data) // Automatically cancelled when lifecycle ends
}
3. Catching CancellationException:
// ❌ DON'T - Catches CancellationException
try {
val data = api.fetchData()
} catch (e: Exception) { // This catches CancellationException!
// Handle error
}
// ✅ DO - Let CancellationException propagate
try {
val data = api.fetchData()
} catch (e: HttpException) {
// Handle specific errors
} catch (e: IOException) {
// Handle network errors
}
Testing Coroutines
class UserViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `loadUserProfile success`() = runTest {
// Arrange
val mockApi = mockk<UserApi>()
val user = User(1, "John Doe", "john@example.com", "")
coEvery { mockApi.getUser(1) } returns user
val viewModel = UserViewModel(mockApi, mockUserDao)
// Act
viewModel.loadUserProfile(1)
// Assert
val state = viewModel.uiState.value
assertTrue(state is UserViewModel.UiState.Success)
assertEquals(user, (state as UserViewModel.UiState.Success).user)
}
}
Performance Tips
1. Use Flow for streams of data:
fun observeUsers(): Flow<List<User>> = userDao.getAllUsers()
.map { users -> users.sortedBy { it.name } }
2. Cancel unused coroutines:
val job = lifecycleScope.launch {
// Long-running operation
}
// Cancel when no longer needed
job.cancel()
3. Use conflate() for high-frequency updates:
locationFlow
.conflate() // Skip intermediate values if consumer is slow
.collect { location ->
updateMap(location)
}
The Bottom Line
Kotlin coroutines make asynchronous Android development actually enjoyable. No callback hell. No thread management nightmares. Just clean, readable code that does what it says.
Want to level up? Explore Kotlin DSL builders for expressive APIs, integrate coroutines into mobile app state management patterns, or compare with Dart's async patterns in Flutter.
Key takeaways:
- Use
lifecycleScopeorviewModelScope- neverGlobalScope - Pick the right dispatcher: Main for UI, IO for network/database, Default for CPU work
- Handle errors properly with try-catch
- Use
asyncfor parallel operations - Test with
runTestfrom kotlinx-coroutines-test
Your users will thank you with their fingers - smooth scrolling, no ANR dialogs, and a responsive app that doesn't make them want to throw their phone.
Go fix those blocking calls!
Sources: