Kotlin Coroutines: Do's and Don'ts

Best practices for writing efficient and maintainable asynchronous code

Kotlin coroutines provide a powerful way to handle asynchronous programming, but with great power comes the need for proper understanding. This guide will help you avoid common pitfalls and write better coroutine-based code.

Scope Management

DO: Use Structured Concurrency

Always launch coroutines within a proper scope to ensure proper lifecycle management and automatic cancellation.

class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            val data = fetchData()
            // Update UI
        }
    }
}

DON'T: Use GlobalScope

Avoid GlobalScope as it creates coroutines that live for the entire application lifetime and won't be automatically cancelled.

// ❌ Bad practice
GlobalScope.launch {
    fetchData()
}

Dispatcher Selection

DO: Choose the Right Dispatcher

Use Dispatchers.IO for network/disk operations, Dispatchers.Default for CPU-intensive work, and Dispatchers.Main for UI updates.

withContext(Dispatchers.IO) {
    val data = database.query()
}

withContext(Dispatchers.Default) {
    processLargeDataSet(data)
}

DON'T: Block the Main Thread

Never perform blocking operations on the main dispatcher without switching context.

// ❌ Bad - blocks UI thread
viewModelScope.launch(Dispatchers.Main) {
    val data = Thread.sleep(1000)
}

Exception Handling

DO: Handle Exceptions Properly

Use try-catch blocks or CoroutineExceptionHandler to handle exceptions gracefully.

viewModelScope.launch {
    try {
        val result = fetchData()
    } catch (e: Exception) {
        handleError(e)
    }
}

DON'T: Ignore Exceptions

Unhandled exceptions in coroutines can crash your app or cause silent failures.

// ❌ Bad - exception might be lost
launch {
    riskyOperation() // May throw exception
}

Cancellation

DO: Make Code Cancellation-Aware

Check for cancellation in long-running operations and use cancellable suspending functions.

withContext(Dispatchers.Default) {
    repeat(1000) { i ->
        ensureActive() // Check cancellation
        processItem(i)
    }
}

DON'T: Ignore Cancellation

Don't perform long computations without checking if the coroutine is still active.

// ❌ Bad - won't respond to cancellation
repeat(1000000) { i ->
    complexCalculation(i)
}

Flow and LiveData

DO: Use Flow for Streams of Data

Prefer Flow for reactive streams and cold data sources that can be cancelled and transformed.

fun observeUsers(): Flow<List<User>> = flow {
    while (true) {
        emit(database.getUsers())
        delay(5000)
    }
}

DON'T: Mix Blocking and Suspending Code

Don't use runBlocking in production code except for main functions and tests.

// ❌ Bad - blocks the thread
fun getData() = runBlocking {
    fetchData()
}

Performance

DO: Use async for Parallel Operations

When you need multiple independent operations to run concurrently, use async and await.

val user = async { fetchUser() }
val posts = async { fetchPosts() }

val result = Result(user.await(), posts.await())

DON'T: Create Unnecessary Coroutines

Avoid launching coroutines for operations that are already suspending.

// ❌ Bad - unnecessary launch
launch {
    suspendingFunction()
}

// ✓ Good - just call it
suspendingFunction()

Testing

DO: Use TestDispatchers

Use TestCoroutineDispatcher or StandardTestDispatcher for deterministic testing.

@Test
fun testCoroutine() = runTest {
    val viewModel = MyViewModel()
    viewModel.loadData()
    advanceUntilIdle()
    assertEquals(expected, viewModel.state)
}

DON'T: Use Real Delays in Tests

Never use actual delays in unit tests as they make tests slow and flaky.

// ❌ Bad - makes tests slow
@Test
fun testWithDelay() {
    launch { delay(1000) }
    Thread.sleep(1500)
}