There's been some noise around coroutines getting faster on Android - so let's dig into what actually changed.
Starting with AGP 9.2.0, R8 rewrites most Atomic*FieldUpdater calls into direct Unsafe calls. Sounds narrow. But kotlinx.atomicfu is built on those updaters, and kotlinx.coroutines is built on atomicfu - so it lands on every coroutine you launch.
Google's benchmark, Pixel 5 (API 33), JIT warmed up:
| Operation | Time |
|---|---|
AtomicReference.compareAndSet | 50.7 ns |
atomicfu.compareAndSet | 135 ns |
~2.7x slower, and ART wasn't quietly optimizing the gap away.
Where the cost hides
Coroutines keep a parent-child tree of Jobs - that's what makes structured concurrency work. The tree is lock-free, so every insert and removal is a CAS loop. Launching does several. Cancelling does several more.
Now look at how an updater gets built:
static final AtomicReferenceFieldUpdater updater = AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");The field is identified by a string. "data". And a string-based contract has to be defended at runtime, so every call verifies that the holder is really an instance of the declared class, that the new value really matches the declared field type, and that access is permitted - then does the one line of real work.
The annoying part is that you'd never find this profiling your own code, because none of it is your code. It took an ART method trace of an empty LaunchedEffect { } to make it visible. An empty effect. Doing nothing. Mostly reflection. 🤡
How R8 kills it
None of what's being verified can change. The updater is static final, built with constant arguments, pointing at a field in the same class - yet it's re-checked on every call, forever. R8 sees the whole program at the end of the build, so it answers the question once:
Instrument - add an offset constant next to the updater field. That's what
Atomic*FieldUpdaterholds internally anyway, minus the validation. The original field stays for now, which is what allows partial optimization.Replace - convert a call site only if R8 can prove the updater traces back to an instrumented field, the holder is the declared holder type or a subclass, and the new value is the declared field type or a subclass:
javaSyntheticUnsafe.UNSAFE.compareAndSwapObject( holder, Example.updater$offset, expected, newValue );Clean up - drop whichever field went unused. Trickier than it sounds:
newUpdaterandgetDeclaredFieldcan throw, so R8 had to explicitly reason about which instrumented fields are exception-free before deleting the initializers.
But... isn't that just skipping the safety checks?
A burning question -
If the reflection was validating something, and now it isn't running - surely something got weaker?
No. The checks aren't skipped, they're moved. Those three preconditions in step 2 are the runtime checks, evaluated by the type system instead of by reflection. Fail to prove one, and that call site keeps the slow path.
The delicate part was nulls - where the updater would throw, Unsafe is undefined behaviour, usually a native crash. So R8 inserts explicit null checks unless it can rule them out. That's paying a cost to preserve behaviour, not skipping one.
What does this mean for us?
Scope the 2x honestly: it came from Compose micro-benchmarks around LaunchedEffect, and an empty LaunchedEffect { } is pure bookkeeping. Strip reflection out of the bookkeeping, and you halve the total, because nothing else is in there. Your coroutines, which presumably do something, will see less.
It still matters because Compose leans on coroutines everywhere - 80% of the cost of creating and updating Modifier.clickable was launching and cancelling internal coroutines for InteractionSource updates. Long lists of interactive rows qualify.
But... it's nanoseconds?
One launch is a few CAS ops - a microsecond of bookkeeping, invisible alone. The multiplier is frequency: a fling creates and cancels coroutines with every row that crosses the viewport - dozens of times a second, against an 8.3ms frame budget at 120Hz. Jank lives at the margin. Same reason a faster malloc matters - no single allocation does.
Two more: atomicfu now matches plain AtomicReference under R8, sometimes beating it since its compiler plugin inlines atomics into fields and skips an allocation. And ART is implementing the same thing natively - apps targeting API 37 on recent ART builds saw ~15% from JIT changes alone.
Update your AGP and take the win.

