Android Diary / 6 min read
R8 Just Made Kotlin Coroutines on Android 2x Faster and You Didn't Have to Touch a Single Line

Picture this: you bump your Android Gradle Plugin version, rebuild, run the app exactly as before — no code changes — and suddenly every LaunchedEffect in your Compose UI launches and cancels twice as fast. No magic involved. That's exactly what happens starting with AGP 9.2.0, and the engineer behind the curtain isn't a new coroutine API — it's R8, the compiler most of us only think about when it breaks our ProGuard rules.
The Compose team recently walked through how they found and fixed this, and for anyone shipping coroutine-heavy Android apps — including enterprise software running on constrained embedded hardware like Android TV boxes or set-top devices — this is one of the more elegant optimizations I've come across in a while: not a rewrite, but teaching the compiler to safely cut a corner millions of times a second.
The suspect nobody expected
While profiling Compose's runtime, the team kept running into coroutines as the hidden cost behind operations that look nothing like async code from the outside. The number that stood out: roughly 80% of the time spent creating and updating Modifier.clickable was going toward launching and cancelling an internal coroutine that just tracks InteractionSource updates. One deceptively simple modifier, quietly running a coroutine machine underneath every state change.
This is the kind of finding that resonates with anyone working on Android TV or signage-style apps, where small animations, remote-control focus listeners, and background content refresh cycles add up to hundreds of coroutine launch-cancel pairs per minute. A tiny hidden cost per launch doesn't show up in Logcat — it shows up as jank.
Catching the culprit with an ART method trace
The most honest way to see where CPU time actually goes is recording an ART method trace and inspecting it in Perfetto. Even an empty LaunchedEffect { } call breaks down into three distinct phases: initializing a new coroutine, launching it, and completing it (since it exits immediately). Cancellation follows a similar path, with the added cost of constructing a CancellationException.

What jumps out immediately is how often java.util.concurrent.AtomicReferenceFieldUpdater shows up — small, individually fast calls repeated at an alarming frequency. Zooming in reveals that most of that time isn't spent doing actual work at all — it's spent on reflective safety checks.

Why AtomicReferenceFieldUpdater is slow in the first place
Under the hood, kotlinx.coroutines builds a lock-free tree structure to track parent-child relationships, which is what makes structured concurrency work smoothly. The library backing those atomics, kotlinx.atomicfu, leans on a classic JVM primitive: AtomicReferenceFieldUpdater. The catch is that this updater resolves a class reference and field name dynamically, and every single use has to pass a chain of reflective safety checks — does the field exist, is it accessible, and so on.
Every phase of a coroutine's lifecycle — start, suspend, cancel, complete — triggers at least one atomic operation. If that operation carries even a small tax, the cost compounds fast across a whole app.
The team didn't take the trace at face value, though — it could just as easily be an artifact of instrumentation rather than real overhead. So they wrote a microbenchmark pitting kotlinx.atomicfu against plain java.util.concurrent.atomic, roughly along these lines:
class AtomicBenchmark {
private val jucRef = java.util.concurrent.atomic.AtomicReference(false)
private val atomicFuRef = kotlinx.atomicfu.atomic(false)fun benchmarkJuc() = jucRef.compareAndSet(true, false)
fun benchmarkAtomicFu() = atomicFuRef.compareAndSet(true, false)
}
```
On a Pixel 5 (API 33), the results confirmed the suspicion: the atomicfu version came in roughly 2.7x slower than a plain AtomicReference. ART wasn't quietly optimizing the reflective checks away — the overhead was real.
The fix isn't a rewrite — it's a smarter compiler
Here's the part I find genuinely clever. Atomic*FieldUpdater exists to support dynamic, reflection-based use cases, but in practice almost every real-world usage follows a statically predictable pattern: a final field, a known holder class, constant arguments. R8, as a whole-program optimizing compiler, is in a perfect position to recognize that repeated shape and strip out reflective overhead nobody actually needs.
Instead of asking the maintainers of kotlinx.coroutines to rewrite millions of lines of production code, R8 quietly performs a three-stage optimization at the bytecode level:
1. Instrumentation — R8 introduces an offset field alongside the existing updater field, computed via Unsafe.objectFieldOffset(). The original field and its initializer are left untouched — an optimistic approach that keeps partial rollback safe if only some call sites qualify.
2. Replacement — Every call site gets checked: does the updater actually trace back to an instrumented field, and do the holder and value types match statically? If every condition holds, the call is swapped for a low-level Unsafe operation with zero reflective checks.
3. Cleanup — The old updater field and its initializer get removed once nothing references them anymore. This is the trickiest part, since calls like newUpdater() or getDeclaredField() could theoretically throw — so R8 has to explicitly prove the instrumented field is safe from that possibility before it's allowed to delete the code.
The end result: your source code looks exactly the same, but the bytecode running on-device skips the expensive reflective path entirely.
The numbers that make this worth caring about
Once R8 9.2.0 kicked in, kotlinx.atomicfu — along with most explicit uses of AtomicInt/Long/ReferenceFieldUpdater — finally matched plain AtomicReference performance. Compose's internal microbenchmarks, which track coroutine launch and cancellation timing closely to catch regressions early, recorded a 2x improvement the moment the build moved to the new R8 version.

The story doesn't stop at the compiler, either. The ART team has since implemented a similar optimization natively at the VM level. So if your app targets API 36 and runs on a recent Android build, there's a good chance the device is already applying a comparable trick — contributing roughly another 15% on top of the R8 gains, according to the same benchmark.
Why this matters more on constrained hardware
If your day-to-day work is a typical Compose consumer app, this upgrade probably just makes your animations a little smoother without you ever noticing. But for anyone building for managed/STB device fleets — Android boxes, Android TV, kiosk-style hardware where RAM and CPU headroom are nowhere near flagship territory — shaving overhead off every single coroutine matters a lot more. A cheaper LaunchedEffect means more room for everything else running quietly in the background on a device that stays powered on 24/7: network status polling, content transition animations, or continuous state syncing.
The practical takeaway is refreshingly simple: upgrade to AGP 9.2.0 or later, and this optimization lands for free — no new APIs to learn, no library migration required. Sometimes the biggest performance win isn't about writing smarter code yourself. It's about finally giving the compiler underneath you permission to be smarter first.
This piece summarizes and reflects on findings originally published by the Android Developers team (Jonathan Starup & Andrei Shikov). Original source: Android Developers Blog.