article banner

Key advantages of Kotlin Coroutines

Developers often grumble about all the complexity that Kotlin Coroutines hide under the carpet. But this library also hides its best advantages; we often benefit from them without even knowing about it. That is why I decided to start a series about the key advantages of Kotlin Coroutines.

Let’s start with simplicity. With coroutines, we can write simple, imperative code. For backend developers, that is nothing new, but for Android it is a game-changer that we can have that while running on the main thread.

fun onCreate() { viewModelScope.launch { val user = fetchUser() // suspends coroutine displayUser(user) // runs on the main thread val posts = fetchPosts(user) // suspends coroutine displayPosts(posts) // runs on the main thread } }

At the same time, coroutines allow a simple introduction of synchronicity with async/await. That is a well-recognized pattern, but in Kotlin Coroutines it offers structured concurrency with ease.

suspend fun fetchUser(): UserData = coroutineScope { val userDetails = async { api.fetchUserDetails() } val posts = async { api.fetchPosts() } UserData(userDetails.await(), posts.await()) }

Coroutines are also efficient. Coroutines are lighter than threads. Suspending functions are much lighter than Single, and Flow is much lighter than Observable. Coroutines can only be compared to virtual threads from Loom, but it is only because Loom uses its own coroutines under the hood.

Programmers also underestimate the importance of cancellation. It is critical for Android developers where if a view is destroyed, we should also cancel all its processes. Coroutines offer powerful and effortless cancellation thanks to structured concurrency.

fun onCreate() { viewModelScope.launch { // Cancelled when the user leaves the screen val news = getNewsFromApi() val sortedNews = news .sortedByDescending { it.publishedAt } view.showNews(sortedNews) } }

Cancellation mechanisms are also a great benefit for backend developers. If you make async calls and one of them fails, others are cancelled by default.

suspend fun fetchUser(): UserData = coroutineScope { // fetchUserDetails is cancelled if fetchPosts fails val userDetails = async { api.fetchUserDetails() } // fetchPosts is cancelled if fetchUserDetails fails val posts = async { api.fetchPosts() } UserData(userDetails.await(), posts.await()) }

When you define a backend application in a framework like Ktor, that is coroutines-first, if an HTTP connection is lost, call gets immediately cancelled, the same if WebSocket or RSocket connection is lost.

// Ktor server fun Route.messagesApi() { get("/message/statistics") { // Cancelled if HTTP connection is lost val statistics = calculateMessageStatistics() call.respond(statistics) } rSocket("/message/channel") { RSocketRequestHandler { requestChannel { header, control -> // Cancelled if RSocket connection is lost messagesFlow(header, control) } } } }

Synchronization offers numerous powerful tools for synchronizing coroutines. You can easily make one coroutine wait for another coroutine to complete using join or suspend until another coroutine provides a value using CompletableDeferred or Channel.

class SomeService( private val scope: CoroutineScope ) { fun startTasks() { val job = scope.launch { // ... } scope.launch { // ... job.join() // ... } } }

Coroutines also offer the best support for testing I could see in any library. Kotlin Coroutines supports operating in virtual time, which lets us write precise, fast, and deterministic tests for cases that are hard to test with other libraries.

@Test fun `should fetch data asynchronously`() = runTest { val api = mockk<Api> { coEvery { fetchUserDetails() } coAnswers { delay(1000) UserDetails("John Doe") } coEvery { fetchPosts() } coAnswers { delay(1000) listOf(Post("Hello, world!")) } } val useCase = FetchUserDataUseCase(api) val user = useCase.fetchUser() assertEquals(1000, currentTime) }

Virtual time also allows us to test timeouts, retries, and other time-related operations. It also allows us to verify what happens in different scenarios. What if X is faster than Y but slower than Z? What if X is faster than Z? We can easily simulate and test all these scenarios with virtual time!

Kotlin Coroutines provide a powerful abstraction for expressing and processing asynchronous streams of values. This abstraction is called Flow. Flow is much lighter than RxJava or Reactor streams, and its implementation is much easier. It also has a rich feature set with many useful operators.

fun notificationStatusFlow(): Flow<NotificationStatus> = notificationProvider.observeNotificationUpdate() .distinctUntilChanged() .scan(NotificationStatus()) { status, update -> status.applyNotification(update) } .combine(userStateProvider.userStateFlow()) { status, user -> statusFactory.produce(status, user) }

Coroutines are multiplatform, so you can use them in any Kotlin target and in common modules.

That is why I believe that Kotlin Coroutines is currently the best tool in the market for concurrency. Certainly the best for Android, but also the best for the backend, but only if we use its advantages instead of just treating it like “lightweight threads”.