Switching a million lines of code from Java threads to Kotlin coroutines, by rewriting three files | by Adrian Blanco | Jun, 2026 | ProAndroidDevSitemapOpen in appSign up<br>Sign in
Medium Logo
Get app<br>Write
Search
Sign up<br>Sign in
ProAndroidDev
The latest posts from Android Professionals and Google Developer Experts.
Switching a million lines of code from Java threads to Kotlin coroutines, by rewriting three files
Adrian Blanco
13 min read·<br>19 hours ago
Listen
Share
Press enter or click to view image in full size
How we switched one of Denmark’s most widely used Android apps to coroutines overnight.<br>1. Introduction<br>I’ve been working on untangling legacy code in one of our longest running Android apps here at Framna Denmark, and one of the trickiest parts has been our internal legacy threading library, ComponentKit. It was written with Java threads almost a decade ago, and now powers the core architecture and nearly a million lines of code in the app.<br>Building a custom threading library might raise some eyebrows today, but a decade ago each platform was still far from establishing best practices for asynchronous work. ComponentKit filled a gap by providing a threading API with strictly defined behavior, and implementations for Android and iOS. This let us write code that looked similar and behaved the same across both platforms.<br>We had already adopted Kotlin years earlier, so when coroutines emerged as the standard for asynchronous work on Android, we started gradually adopting them too. However, our interop between ComponentKit and coroutines was unintuitive and brittle. Our developers constantly faced hard decisions between sticking to legacy patterns that worked, or using coroutines and praying that our interop wouldn’t break.<br>Eventually, we realized that this wasn’t sustainable. Our only way out of this mess was to push coroutines interop to its limits and rewrite the legacy threading library with Kotlin coroutines, while preserving the existing behavior so that the entire codebase could run on coroutines without rewriting callers.<br>What’s in our legacy library?<br>The core API looks something like this:<br>abstract class Component {<br>protected fun async(block: () -> Unit) = …
interface Promise {<br>// Blocks thread for the result<br>fun waitForResult(): Result
fun setResult(result: Result)
// Callback methods, etc<br>}A Component contains business logic and has a function async to execute work on the component’s thread. Promise is an observable data holder whose waitForResult method blocks the current thread until a result is ready.<br>Component.async controls how work executes on the component's thread, and Promise controls how the caller waits for the result. We have nearly a thousand components, and this is an example of what typical usage can look like:<br>class UserComponent : Component() {<br>fun userName(): Promise {<br>val promise = Promise()<br>// Queues the work on the component's thread<br>async {<br>// Blocks the component's thread until result is ready<br>val userResult = user().waitForResult()<br>promise.setResult(userResult.map { it.name })
// Caller uses Promise to get result<br>return promise<br>}The component’s async implementation defines three invariants:<br>FIFO queuing: If not called from the component’s thread, queue the work on the component’s thread.<br>Reentrance: If called from the component’s thread, execute the work synchronously.<br>Thread acquisition: Work that executes on the thread holds the thread until finished.<br>Calling two async blocks next to each other will always execute in FIFO order.
Output: [A, B]Calling async from the component’s thread will execute synchronously, since we don’t need to switch threads.
Output: [A, B]Coroutines and concurrency<br>Coroutines introduce two potentially breaking behaviors to our existing threading model:<br>Coroutine suspension: Coroutines can suspend mid execution, releasing the current thread and possibly resuming on a different one.<br>Switching dispatchers: Coroutines can switch dispatchers mid execution. Calling a suspending function on a dispatcher doesn’t guarantee the rest of the code in the function will run there.<br>ComponentKit assumes work owns its thread until finished, while coroutines assume threads are borrowed and released freely. Both can’t be true at the same time, but we’ll make it work.<br>Losing the thread<br>Coroutines provide out of the box threads interop, but using it at our library boundaries means developers are forced to keep track of subtle and dangerous edge cases where the interop can break existing behavior.<br>We want developers to be able to use coroutines without having to second guess which coroutines features they are allowed or not allowed to use, and when. This means we need to do a deep rewrite of the threading library so that all the existing Component.async / Promise.waitForResult calls run on coroutines without rewriting callers, while preserving existing behavior.<br>fun async(block: suspend () -> Unit)<br>suspend fun waitForResult(): ResultHowever, before...