Skip to content

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:

UserLoader.kt
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:

UserLoaderWithErrors.kt
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:

ThreadExample.kt
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:

AsyncTaskExample.kt
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)
}
}
// Usage
LoadUserTask { 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:

RxJavaExample.kt
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:

CoroutineExample.kt
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 ViewModel
fun 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:

StructuredConcurrency.kt
class UserViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch { // Auto-cancelled when ViewModel cleared
val user = repository.getUser()
_uiState.value = Success(user)
}
}
}
// In Fragment
lifecycleScope.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:

LightweightTest.kt
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 ~100ms

Each coroutine uses kilobytes, not megabytes like threads. The same test with 100,000 threads would crash the app.

3. Automatic Cancellation Propagation

CancellationExample.kt
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 ViewModel
fun loadData() {
val job = viewModelScope.launch {
fetchAllData()
}
// Later, if user navigates away
job.cancel() // All child coroutines cancelled automatically
}

4. Clear Thread Management with Dispatchers

DispatcherExample.kt
class UserRepository(private val api: ApiService) {
suspend fun loadData(): Result {
return withContext(Dispatchers.IO) {
api.fetchFromNetwork()
}
// Automatically returns to calling thread
}
}
// Usage in ViewModel
viewModelScope.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 vs Coroutine Memory Model
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

GlobalScopeMistake.kt
// WRONG: Lives forever, causes memory leaks
GlobalScope.launch {
val data = fetchData()
updateUI(data) // Activity might be destroyed!
}
// CORRECT: Use lifecycle-aware scopes
viewModelScope.launch {
val data = fetchData()
updateUI(data)
}

Mistake 2: Blocking Instead of Suspending

BlockingMistake.kt
// WRONG: Blocks the thread
suspend fun badExample() {
Thread.sleep(1000) // Blocks entire thread!
}
// CORRECT: Suspends without blocking
suspend fun goodExample() {
delay(1000) // Suspends coroutine, thread is free for other work
}

Mistake 3: Wrong Dispatcher Selection

DispatcherMistake.kt
// WRONG: CPU work on IO dispatcher
withContext(Dispatchers.IO) {
heavyComputation() // IO threads are for waiting, not computing
}
// CORRECT: Match dispatcher to work type
withContext(Dispatchers.Default) {
heavyComputation() // CPU-bound work
}
withContext(Dispatchers.IO) {
networkCall() // I/O-bound work
}

Mistake 4: Ignoring Cancellation

CancellationMistake.kt
// WRONG: Not checking for cancellation
suspend fun downloadFile(): File {
repeat(1000) { chunk ->
downloadChunk(chunk) // Runs even if cancelled!
}
}
// CORRECT: Make code cancellable
suspend 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:

FeatureThreadsAsyncTaskRxJavaCoroutines
Memory UsageHigh (~1MB each)MediumMediumLow (~KB each)
Learning CurveLowLowHighMedium
CancellationManualManualManualAutomatic
Structured ConcurrencyNoNoPartialYes
ReadabilityMediumLowMediumHigh
Error HandlingTry-catchCallbacksOperatorsTry-catch
TestingHardHardMediumEasy
StatusLegacyDeprecatedActiveRecommended

Testing Made Simple

One of my favorite coroutine benefits—testing is straightforward:

UserRepositoryTest.kt
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:

  1. Answer interview questions confidently—explain the “why,” not just the “how”
  2. Debug async issues—structured concurrency makes bugs easier to trace
  3. Migrate legacy code—know when RxJava is still appropriate
  4. Write better apps—automatic cancellation prevents battery drain and memory leaks

When to Use What

Async Solution Decision Tree
+------------------------+
| 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
Coroutines

The Evolution Summary

I now understand why coroutines won:

  1. Readability—Code reads sequentially, executes asynchronously
  2. Safety—Structured concurrency prevents memory leaks
  3. Efficiency—Lightweight design handles thousands of concurrent operations
  4. Testability—Simple testing with runTest and TestDispatcher
  5. 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