Why I Switched to Kotlin Coroutines in Android (And Why You Should Too)
I was staring at four levels of nested callbacks, trying to debug why my user profile screen was showing stale data. The worst part? I couldn’t even tell which callback was failing. That’s when I realized I needed to understand why modern Android development uses coroutines, not just how to use them.
The Problem: I Couldn’t Explain My Tech Choices
During a recent interview, the interviewer asked: “Why do you use coroutines instead of RxJava or threads?”
I gave the standard answer: “Because it’s the recommended approach.” The interviewer nodded, but I saw disappointment. I realized I was using coroutines because tutorials told me to, not because I understood the problems they solved.
Let me walk you through what I learned about Android’s async evolution and why coroutines actually won.
The Callback Hell Nightmare
My first Android project used callbacks everywhere. Here’s what my user loading code looked like:
fun loadUserProfile(callback: (Result<UserProfile>) -> Unit) { api.login(user) { loginResult -> api.getProfile(loginResult.userId) { profile -> api.getOrders(profile.id) { orders -> handler.post { callback(Result(UserProfile(profile, orders))) } } } }}This looks manageable, but then I needed error handling:
fun loadUserProfile(callback: (Result<UserProfile>) -> Unit) { api.login(user) { loginResult -> if (loginResult.error != null) { handler.post { callback(Result.failure(loginResult.error)) } return@login } api.getProfile(loginResult.userId) { profile -> if (profile.error != null) { handler.post { callback(Result.failure(profile.error)) } return@getProfile } api.getOrders(profile.id) { orders -> if (orders.error != null) { handler.post { callback(Result.failure(orders.error)) } return@getOrders } handler.post { callback(Result(UserProfile(profile, orders))) } } } }}The pyramid grew. Error handling duplicated. And when the activity was destroyed mid-load? Memory leaks everywhere.
The Thread Solution (And Its Problems)
I tried creating threads manually for network calls:
fun loadUser() { Thread { val user = api.getUserSync(userId) // Blocking call runOnUiThread { updateUI(user) } }.start()}Seemed simple. Then I profiled my app and discovered:
- Each thread consumed ~1MB of stack memory
- No automatic cancellation when activity died
- Thread creation overhead for every network call
- Race conditions when multiple threads updated the same data
I tried using a thread pool, but managing pool sizes and work queues felt like solving the wrong problem.
The AsyncTask Era (Now Deprecated)
AsyncTask was Android’s official solution for a while:
class LoadUserTask( private val callback: (User) -> Unit) : AsyncTask<String, Void, User>() {
override fun doInBackground(vararg params: String): User { return api.getUserSync(params[0]) }
override fun onPostExecute(result: User) { callback(result) }}
// UsageLoadUserTask { user -> updateUI(user) }.execute(userId)But AsyncTask had critical flaws:
- Memory leaks if I held activity references
- No automatic cancellation
- Deprecated in API 30
- Limited configuration (what if I wanted parallel execution?)
The RxJava Complexity Curve
I tried RxJava. It solved many problems, but introduced new ones:
compositeDisposable.add( api.login(user) .flatMap { result -> api.getProfile(result.userId) } .flatMap { profile -> api.getOrders(profile.id) .map { orders -> UserWithOrders(profile, orders) } } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { data -> updateUI(data) }, { error -> showError(error) } ))This is cleaner than callbacks, but the learning curve was steep. I needed to understand:
- Observable vs Single vs Flowable vs Completable
- Schedulers and thread switching
- Disposables and memory management
- Backpressure and flow control
For simple async operations, RxJava felt like using a sledgehammer for a nail.
The Coroutine Solution
Then I rewrote the same code with coroutines:
suspend fun loadUserWithOrders(): UserWithOrders { val loginResult = api.login(user) val profile = api.getProfile(loginResult.userId) val orders = api.getOrders(profile.id) return UserWithOrders(profile, orders)}
// In ViewModelfun loadData() { viewModelScope.launch { try { val data = loadUserWithOrders() _uiState.value = UiState.Success(data) } catch (e: Exception) { _uiState.value = UiState.Error(e.message ?: "Unknown error") } }}The code reads top-to-bottom, like synchronous code, but executes asynchronously. That’s the magic of suspend functions.
Why Coroutines Actually Work
1. Structured Concurrency (The Big Win)
Coroutines are bound to a scope. When the scope dies, all coroutines in it die too:
class UserViewModel : ViewModel() { fun loadData() { viewModelScope.launch { // Auto-cancelled when ViewModel cleared val user = repository.getUser() _uiState.value = Success(user) } }}
// In FragmentlifecycleScope.launch { // Auto-cancelled when lifecycle ends val data = viewModel.loadData() updateUI(data)}No more memory leaks from forgotten background tasks. No more updating destroyed activities.
2. Lightweight by Design
I tested this after reading the coroutine documentation:
fun demonstrateLightweight() = runBlocking { val start = System.currentTimeMillis()
repeat(100_000) { launch { delay(1000) } }
println("Launched 100,000 coroutines in ${System.currentTimeMillis() - start}ms")}// Output: Launched 100,000 coroutines in ~100msEach coroutine uses kilobytes, not megabytes like threads. The same test with 100,000 threads would crash the app.
3. Automatic Cancellation Propagation
suspend fun fetchAllData(): List<Data> = coroutineScope { val deferred1 = async { api.fetchData1() } val deferred2 = async { api.fetchData2() }
// If coroutineScope is cancelled, both operations stop listOf(deferred1.await(), deferred2.await())}
// In ViewModelfun loadData() { val job = viewModelScope.launch { fetchAllData() }
// Later, if user navigates away job.cancel() // All child coroutines cancelled automatically}4. Clear Thread Management with Dispatchers
class UserRepository(private val api: ApiService) { suspend fun loadData(): Result { return withContext(Dispatchers.IO) { api.fetchFromNetwork() } // Automatically returns to calling thread }}
// Usage in ViewModelviewModelScope.launch { showLoading() val result = repository.loadData() // Switches to IO internally hideLoading() updateUI(result) // Back on main thread}The withContext function suspends (not blocks) while switching threads.
The Mental Model Shift
The key insight: coroutines don’t replace threads—they replace thread usage patterns.
Thread Model:+-------------------+ +-------------------+| Thread 1 (1MB) | | Thread 2 (1MB) || +-------------+ | | +-------------+ || | Stack | | | | Stack | || | (fixed) | | | | (fixed) | || +-------------+ | | +-------------+ |+-------------------+ +-------------------+
Coroutine Model:+--------------------------------------------------+| Shared Thread Pool (~CPU cores count) || +----------+ +----------+ +----------+ || | Coro 1 | | Coro 2 | | Coro 3 | ... || | (~KB) | | (~KB) | | (~KB) | || +----------+ +----------+ +----------+ |+--------------------------------------------------+Common Mistakes I Made
Mistake 1: Using GlobalScope
// WRONG: Lives forever, causes memory leaksGlobalScope.launch { val data = fetchData() updateUI(data) // Activity might be destroyed!}
// CORRECT: Use lifecycle-aware scopesviewModelScope.launch { val data = fetchData() updateUI(data)}Mistake 2: Blocking Instead of Suspending
// WRONG: Blocks the threadsuspend fun badExample() { Thread.sleep(1000) // Blocks entire thread!}
// CORRECT: Suspends without blockingsuspend fun goodExample() { delay(1000) // Suspends coroutine, thread is free for other work}Mistake 3: Wrong Dispatcher Selection
// WRONG: CPU work on IO dispatcherwithContext(Dispatchers.IO) { heavyComputation() // IO threads are for waiting, not computing}
// CORRECT: Match dispatcher to work typewithContext(Dispatchers.Default) { heavyComputation() // CPU-bound work}
withContext(Dispatchers.IO) { networkCall() // I/O-bound work}Mistake 4: Ignoring Cancellation
// WRONG: Not checking for cancellationsuspend fun downloadFile(): File { repeat(1000) { chunk -> downloadChunk(chunk) // Runs even if cancelled! }}
// CORRECT: Make code cancellablesuspend fun downloadFile(): File { repeat(1000) { chunk -> ensureActive() // Throws CancellationException if cancelled downloadChunk(chunk) }}The Comparison Table
After understanding all options, here’s my honest comparison:
| Feature | Threads | AsyncTask | RxJava | Coroutines |
|---|---|---|---|---|
| Memory Usage | High (~1MB each) | Medium | Medium | Low (~KB each) |
| Learning Curve | Low | Low | High | Medium |
| Cancellation | Manual | Manual | Manual | Automatic |
| Structured Concurrency | No | No | Partial | Yes |
| Readability | Medium | Low | Medium | High |
| Error Handling | Try-catch | Callbacks | Operators | Try-catch |
| Testing | Hard | Hard | Medium | Easy |
| Status | Legacy | Deprecated | Active | Recommended |
Testing Made Simple
One of my favorite coroutine benefits—testing is straightforward:
class UserRepositoryTest { private val testDispatcher = StandardTestDispatcher()
@Before fun setup() { Dispatchers.setMain(testDispatcher) }
@After fun tearDown() { Dispatchers.resetMain() }
@Test fun `getUser returns user from api`() = runTest { // Arrange val mockApi = mockk<UserApi>() coEvery { mockApi.getUser("123") } returns User("123", "John")
val repository = UserRepository(mockApi, testDispatcher)
// Act val result = repository.getUser("123")
// Assert assertTrue(result.isSuccess) assertEquals("John", result.getOrNull()?.name) }}The runTest function provides a test scope that waits for all coroutines to complete. No more flaky async tests.
Why This Matters for Your Career
Understanding coroutine fundamentals helps you:
- Answer interview questions confidently—explain the “why,” not just the “how”
- Debug async issues—structured concurrency makes bugs easier to trace
- Migrate legacy code—know when RxJava is still appropriate
- Write better apps—automatic cancellation prevents battery drain and memory leaks
When to Use What
+------------------------+| Need async operation? |+------------------------+ | v+------------------------+| Simple, one-time task? |---Yes---> Coroutines with viewModelScope/lifecycleScope+------------------------+ | No v+------------------------+| Complex stream with |---Yes---> Flow (coroutine-based reactive streams)| multiple operators? |+------------------------+ | No v+------------------------+| Need backpressure or |---Yes---> Flow with buffer strategies| rate limiting? |+------------------------+ | No v+------------------------+| Already using RxJava |---Yes---> Stick with RxJava for consistency| extensively? |+------------------------+ | No v CoroutinesThe Evolution Summary
I now understand why coroutines won:
- Readability—Code reads sequentially, executes asynchronously
- Safety—Structured concurrency prevents memory leaks
- Efficiency—Lightweight design handles thousands of concurrent operations
- Testability—Simple testing with
runTestandTestDispatcher - Ecosystem—Jetpack libraries are coroutine-first (Room, DataStore, WorkManager)
The next time someone asks “why coroutines,” I can explain the evolution from callback hell through AsyncTask and RxJava to the coroutine solution—not just say “because Google recommends it.”
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments