I spent years writing callback-based Android code. Nested callbacks inside callbacks, trying to chain API calls, handling errors at every level. It worked, but reading that code six months later was painful. Coroutines changed that completely.
If you're still on callbacks or RxJava and wondering if coroutines are worth learning - they are. Not because they're new and shiny, but because they make async code look like regular sequential code. That's it. That's the whole pitch.
The Problem Coroutines Solve
Here's what loading data looked like with callbacks in a document scanner app I worked on:
// The callback nightmare - actual pattern from production code
fun processScannedDocument(bitmap: Bitmap) {
imageProcessor.detectEdges(bitmap, object : Callback<EdgeResult> {
override fun onSuccess(edges: EdgeResult) {
imageProcessor.cropToEdges(bitmap, edges, object : Callback<Bitmap> {
override fun onSuccess(cropped: Bitmap) {
imageProcessor.enhanceContrast(cropped, object : Callback<Bitmap> {
override fun onSuccess(enhanced: Bitmap) {
ocrEngine.extractText(enhanced, object : Callback<String> {
override fun onSuccess(text: String) {
runOnUiThread {
showResult(enhanced, text)
}
}
override fun onError(e: Exception) {
runOnUiThread { showError(e) }
}
})
}
override fun onError(e: Exception) {
runOnUiThread { showError(e) }
}
})
}
override fun onError(e: Exception) {
runOnUiThread { showError(e) }
}
})
}
override fun onError(e: Exception) {
runOnUiThread { showError(e) }
}
})
}
Four levels deep. Each callback needs its own error handling. The actual business logic - detect edges, crop, enhance, OCR - is buried in ceremony.
Here's the same thing with coroutines:
suspend fun processScannedDocument(bitmap: Bitmap): DocumentResult {
return try {
val edges = imageProcessor.detectEdges(bitmap)
val cropped = imageProcessor.cropToEdges(bitmap, edges)
val enhanced = imageProcessor.enhanceContrast(cropped)
val text = ocrEngine.extractText(enhanced)
DocumentResult.Success(enhanced, text)
} catch (e: Exception) {
DocumentResult.Error(e.message ?: "Processing failed")
}
}
Sequential code that happens to be async. One try-catch handles all errors. The processing pipeline is obvious at a glance.
Suspend Functions - The Building Block
The suspend keyword marks functions that can pause and resume. When you call a suspend function, it doesn't block the thread - it suspends execution, frees the thread for other work, and resumes when the result is ready.
// This doesn't block the main thread
suspend fun calculateExpenseSplit(
participants: List<Participant>,
items: List<ExpenseItem>
): SplitResult {
return withContext(Dispatchers.Default) {
// Complex calculation happens off main thread
val individualShares = mutableMapOf<String, Double>()
items.forEach { item ->
val sharers = item.sharedBy
val perPersonCost = item.amount / sharers.size
sharers.forEach { sharerId ->
individualShares[sharerId] =
(individualShares[sharerId] ?: 0.0) + perPersonCost
}
}
SplitResult(individualShares, calculateSettlements(individualShares))
}
}
You can only call suspend functions from other suspend functions or from a coroutine scope. The compiler enforces this - try calling a suspend function from a regular function and you'll get an error.
Dispatchers - Where Your Code Runs
This confused me at first. Dispatchers determine which thread pool your coroutine uses:
Dispatchers.Main - The UI thread. Use for updating views, showing dialogs. Never do heavy work here.
Dispatchers.IO - Optimized for blocking IO operations. Network calls, file reads, database queries. Can spin up more threads than CPU cores because these operations spend most time waiting.
Dispatchers.Default - CPU-intensive work. Image processing, complex calculations, parsing large data. Thread count matches CPU cores.
Here's how I use them in a podcast app:
class EpisodeDownloader @Inject constructor(
private val api: PodcastApi,
private val storage: EpisodeStorage
) {
suspend fun downloadEpisode(episode: Episode): DownloadResult {
return withContext(Dispatchers.IO) {
try {
// Download audio file
val audioBytes = api.downloadAudio(episode.audioUrl)
// Save to local storage
val localPath = storage.saveAudioFile(episode.id, audioBytes)
// Update database
storage.markAsDownloaded(episode.id, localPath)
DownloadResult.Success(localPath)
} catch (e: Exception) {
DownloadResult.Error(e.message ?: "Download failed")
}
}
}
suspend fun transcribeEpisode(audioPath: String): String {
return withContext(Dispatchers.Default) {
// CPU-heavy transcription work
transcriptionEngine.processAudio(audioPath)
}
}
}
The withContext block switches to the specified dispatcher, does the work, and automatically switches back when done. No manual thread management.
Structured Concurrency - Why It Matters
This feature saves you from memory leaks. Every coroutine runs inside a scope, and when that scope is cancelled, all coroutines inside it get cancelled too.
In Android, you typically use these scopes:
viewModelScope - Tied to ViewModel lifecycle. Cancelled when ViewModel is cleared.
lifecycleScope - Tied to Activity/Fragment lifecycle. Cancelled when destroyed.
class ParkingFinderViewModel @Inject constructor(
private val parkingRepository: ParkingRepository,
private val locationProvider: LocationProvider
) : ViewModel() {
private val _parkingState = MutableStateFlow<ParkingState>(ParkingState.Idle)
val parkingState: StateFlow<ParkingState> = _parkingState.asStateFlow()
fun findNearbyParking() {
// This coroutine automatically cancels if ViewModel is cleared
viewModelScope.launch {
_parkingState.value = ParkingState.Searching
try {
val location = locationProvider.getCurrentLocation()
val spots = parkingRepository.findAvailableSpots(
latitude = location.latitude,
longitude = location.longitude,
radiusMeters = 500
)
_parkingState.value = ParkingState.Found(spots)
} catch (e: Exception) {
_parkingState.value = ParkingState.Error(
e.message ?: "Failed to find parking"
)
}
}
}
}
User navigates away, ViewModel gets cleared, coroutine gets cancelled. No stale location updates, no memory leaks from holding references to destroyed activities.
Parallel Execution with async/await
Sometimes you need multiple things at once. A pet care app might need to load pet profile, upcoming vet appointments, and feeding schedule - all from different sources.
Sequential approach (slow):
// Takes ~3 seconds (1 + 1 + 1)
suspend fun loadPetDashboard(petId: String): PetDashboard {
val profile = petRepository.getProfile(petId) // 1 second
val appointments = vetRepository.getUpcoming(petId) // 1 second
val feedingSchedule = scheduleRepository.get(petId) // 1 second
return PetDashboard(profile, appointments, feedingSchedule)
}
Parallel approach (fast):
// Takes ~1 second (all run simultaneously)
suspend fun loadPetDashboard(petId: String): PetDashboard = coroutineScope {
val profile = async { petRepository.getProfile(petId) }
val appointments = async { vetRepository.getUpcoming(petId) }
val feedingSchedule = async { scheduleRepository.get(petId) }
PetDashboard(
profile = profile.await(),
appointments = appointments.await(),
feedingSchedule = feedingSchedule.await()
)
}
async starts a coroutine that returns a Deferred - basically a promise. await() suspends until the result is ready. The three calls run simultaneously, so total time is the slowest one, not the sum.
That coroutineScope wrapper is important. If any of the async calls fail, it cancels the others and throws the exception. Without it, you might have orphaned coroutines still running after an error.
Exception Handling
Coroutine exception handling has some quirks that bit me early on.
For launch, uncaught exceptions propagate to the parent scope:
viewModelScope.launch {
throw RuntimeException("Boom") // Crashes the app
}
For async, exceptions are held until you call await():
viewModelScope.launch {
val deferred = async { throw RuntimeException("Boom") }
// Exception thrown here when await() is called
deferred.await()
}
I always wrap coroutine bodies in try-catch for any operation that can fail:
viewModelScope.launch {
try {
val result = riskyOperation()
handleSuccess(result)
} catch (e: CancellationException) {
// Don't catch cancellation - rethrow it
throw e
} catch (e: Exception) {
handleError(e)
}
}
That CancellationException check is critical. Coroutine cancellation works by throwing CancellationException. If you catch and swallow it, your coroutine won't actually cancel. Always rethrow it.
Timeouts
External services hang sometimes. Users hate waiting forever. Use withTimeout to set limits:
suspend fun checkParkingAvailability(spotId: String): AvailabilityStatus {
return try {
withTimeout(5000) { // 5 seconds max
parkingApi.checkRealTimeStatus(spotId)
}
} catch (e: TimeoutCancellationException) {
AvailabilityStatus.Unknown // Fallback on timeout
}
}
Or withTimeoutOrNull for a cleaner null fallback:
suspend fun checkParkingAvailability(spotId: String): AvailabilityStatus {
return withTimeoutOrNull(5000) {
parkingApi.checkRealTimeStatus(spotId)
} ?: AvailabilityStatus.Unknown
}
I use 10-15 second timeouts for network calls and 30 seconds for file operations. Anything longer and users have already given up.
Flow for Streams of Data
suspend functions return one value. Flow returns multiple values over time - like tracking a delivery or monitoring plant sensor data.
// In repository - emits location updates for a delivery
fun trackDelivery(orderId: String): Flow<DeliveryLocation> = flow {
while (currentCoroutineContext().isActive) {
val location = deliveryApi.getCurrentLocation(orderId)
emit(location)
if (location.status == DeliveryStatus.DELIVERED) {
break
}
delay(10_000) // Check every 10 seconds
}
}
// In ViewModel
class DeliveryTrackingViewModel @Inject constructor(
private val repository: DeliveryRepository
) : ViewModel() {
private val _deliveryLocation = MutableStateFlow<DeliveryLocation?>(null)
val deliveryLocation: StateFlow<DeliveryLocation?> = _deliveryLocation
fun startTracking(orderId: String) {
viewModelScope.launch {
repository.trackDelivery(orderId)
.catch { e ->
// Handle errors without crashing the flow
_deliveryLocation.value = null
}
.collect { location ->
_deliveryLocation.value = location
}
}
}
}
The flow keeps emitting location updates until the delivery is complete or the scope is cancelled.
Cancellation - Making It Work Right
Coroutines are cancellable, but your code needs to cooperate. Long-running operations should check for cancellation:
suspend fun batchProcessPhotos(photos: List<PhotoUri>): List<ProcessedPhoto> {
return photos.map { photo ->
// Check if we should stop before processing each photo
ensureActive()
withContext(Dispatchers.Default) {
val bitmap = loadBitmap(photo)
val resized = resizeBitmap(bitmap, maxWidth = 1080)
val compressed = compressToJpeg(resized, quality = 85)
ProcessedPhoto(photo.id, compressed)
}
}
}
ensureActive() throws CancellationException if the coroutine was cancelled. Without it, your loop keeps running even after the user navigated away.
For CPU-heavy work, use yield() to give cancellation a chance:
suspend fun analyzeExpensePatterns(
transactions: List<Transaction>
): SpendingAnalysis {
return withContext(Dispatchers.Default) {
val categories = mutableMapOf<String, Double>()
transactions.forEach { transaction ->
yield() // Check for cancellation periodically
val category = categorizeTransaction(transaction)
categories[category] = (categories[category] ?: 0.0) + transaction.amount
}
SpendingAnalysis(categories, calculateTrends(categories))
}
}
Testing Coroutines
One of the best things about coroutines - they're testable without waiting for real time. Use runTest from the coroutines test library:
class ParkingFinderViewModelTest {
@Test
fun `findNearbyParking updates state with spots on success`() = runTest {
val fakeRepository = FakeParkingRepository()
fakeRepository.setSpots(listOf(
ParkingSpot("1", "Main St Garage", available = true),
ParkingSpot("2", "Central Lot", available = true)
))
val fakeLocation = FakeLocationProvider(lat = 37.7749, lng = -122.4194)
val viewModel = ParkingFinderViewModel(fakeRepository, fakeLocation)
viewModel.findNearbyParking()
// runTest automatically advances virtual time
val state = viewModel.parkingState.value
assertTrue(state is ParkingState.Found)
assertEquals(2, (state as ParkingState.Found).spots.size)
}
@Test
fun `findNearbyParking shows error when location unavailable`() = runTest {
val fakeRepository = FakeParkingRepository()
val fakeLocation = FakeLocationProvider(shouldFail = true)
val viewModel = ParkingFinderViewModel(fakeRepository, fakeLocation)
viewModel.findNearbyParking()
assertTrue(viewModel.parkingState.value is ParkingState.Error)
}
}
runTest handles virtual time - delays complete instantly, timeouts work correctly, and you don't wait for real time to pass.
Mistakes I Made So You Don't Have To
Using GlobalScope - Just don't. It's not tied to any lifecycle, so those coroutines run forever. Memory leaks guaranteed.
// Don't do this
GlobalScope.launch {
syncPetData()
}
// Do this instead
viewModelScope.launch {
syncPetData()
}
Blocking the Main Thread - runBlocking blocks the current thread until complete. On Main thread, that freezes your UI.
// This freezes the UI
fun onScanButtonClick() {
runBlocking {
processDocument(bitmap) // UI frozen until complete
}
}
// This doesn't
fun onScanButtonClick() {
viewModelScope.launch {
processDocument(bitmap) // UI stays responsive
}
}
Forgetting withContext for Heavy Work - If you're doing CPU or IO work without specifying a dispatcher, you might be running it on Main.
// This runs on Main thread - blocks UI!
viewModelScope.launch {
val enhanced = imageProcessor.enhance(bitmap) // Heavy work on Main
}
// This is correct
viewModelScope.launch {
val enhanced = withContext(Dispatchers.Default) {
imageProcessor.enhance(bitmap)
}
}
Catching CancellationException - If you catch all exceptions, you're breaking cancellation.
// Broken cancellation
try {
downloadEpisode(episode)
} catch (e: Exception) {
// CancellationException caught and swallowed - coroutine won't cancel!
log(e)
}
// Correct
try {
downloadEpisode(episode)
} catch (e: CancellationException) {
throw e // Always rethrow
} catch (e: Exception) {
log(e)
}
Creating New Scopes Inside Coroutines - This breaks structured concurrency.
// Bad - orphaned scope, not cancelled with parent
viewModelScope.launch {
CoroutineScope(Dispatchers.IO).launch {
riskyOperation() // Keeps running even if viewModel cleared
}
}
// Good - child coroutine, cancelled with parent
viewModelScope.launch {
withContext(Dispatchers.IO) {
riskyOperation()
}
}
When Not to Use Coroutines
Coroutines aren't always the answer:
Simple synchronous code - If it's not async, don't make it async. Adding coroutines to code that doesn't need them just adds complexity.
One-shot callbacks from libraries - A single callback from a third-party library is fine. You can wrap it with suspendCoroutine, but sometimes the effort isn't worth it for a single call.
When the team doesn't know them - If nobody on the team understands coroutines, introducing them in a critical feature is risky. Train first, then adopt.
Wrapping Up
Coroutines took me a few weeks to really internalize. The mental shift from callbacks to sequential async code was the hardest part. Once that clicked, everything else followed.
Start small - convert one callback-heavy function to suspend and see how it feels. Then gradually expand. The patterns here - dispatchers, structured concurrency, exception handling, cancellation - cover most of what you'll need in production.
The code is cleaner, easier to test, and most importantly, easier to understand when you come back to it months later. That alone makes coroutines worth the learning curve.