Performance 9 min read

R8 Makes Kotlin Coroutines 2x Faster in AGP 9.2 — and You Don't Change a Single Line

There's a certain category of performance win that I love: the kind where a toolchain improvement lands, your benchmarks quietly get better, and you did absolutely nothing to earn it. AGP 9.2.0 shipped one of those last week. The Android team published the deep dive on July 27, and if you're running coroutines — which means if you're using any modern Jetpack library, any Compose screen with a LaunchedEffect, or really any app written in the last three years — this matters to you.

The short version: R8 now rewrites Atomic*FieldUpdater calls into direct Unsafe operations at compile time, eliminating the reflective overhead those updaters carry. kotlinx.coroutines uses AtomicReferenceFieldUpdater internally for every single coroutine lifecycle transition — start, suspend, resume, cancel, complete. Which means that 2x improvement on raw atomic ops translates directly into 2x on coroutine launch and cancel. And in Compose, where LaunchedEffect fires constantly inside Modifier.clickable, that 2x shows up in your actual UI benchmark traces.

2x
Coroutine launch & cancel speedup
50.7 ns
atomicfu compareAndSet after optimization (was 135 ns)
AGP 9.2+
Fires automatically, no opt-in

Why Coroutines Were Slower Than They Needed to Be

To understand what R8 fixed, you need to know what AtomicReferenceFieldUpdater is and why it exists. In Java, if you want lock-free access to a volatile field — the classic pattern for concurrent data structures — you have two options. You can use an AtomicReference wrapper object, which allocates heap memory per field. Or you can use AtomicReferenceFieldUpdater, which operates on the volatile field directly, avoiding the allocation.

Coroutine implementations need lock-free tree structures for parent-child relationships — that's how structured concurrency works. Every scope knows its children, every child knows its parent, and cancelling a parent cascades down the tree atomically. The obvious choice for this is AtomicReferenceFieldUpdater: no allocations, correct semantics. The kotlinx.atomicfu library, which kotlinx.coroutines uses internally, wraps this in an idiomatic Kotlin API.

The hidden cost: every AtomicReferenceFieldUpdater operation involves reflective field lookups. The JVM's JIT compiler can warm these up over time, but on Android — where AOT compilation means code runs cold more often than on server JVMs — the overhead is consistent and measurable. The R8 team built a Pixel 5 benchmark on API 33 and found kotlinx.atomicfu.compareAndSet() running at 135 ns, versus 50.7 ns for an equivalent AtomicReference.compareAndSet(). That's 2.7x slower, for every atomic state transition in every coroutine.

The Three-Step Optimization

R8's fix is elegant because it doesn't change the semantics of the code — it changes the mechanism at compile time, after R8 can prove the transformation is safe. Three things happen to every Atomic*FieldUpdater that passes static analysis:

Step 1: Field offset instrumentation

R8 introduces a companion long field that captures the field's memory offset using Unsafe.objectFieldOffset(). This offset calculation happens once at class loading time, not on every atomic operation:

// R8 synthesizes this alongside the original class
static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(
        Example.class.getDeclaredField("data")
    );

Step 2: Call site replacement

At every point where the updater was used, R8 replaces the reflective call with a direct Unsafe intrinsic. The JVM knows exactly how to optimize these at the lowest level — no reflection machinery in the hot path:

// Original call site:
updater.compareAndSet(holder, expectedValue, newValue);

// After R8 optimization:
SyntheticUnsafe.UNSAFE.compareAndSwapObject(
    holder, Example.updater$offset, expectedValue, newValue
);

The transformation fires when R8 can statically prove three things: the updater value traces back to an instrumented field, the holder is compatible with the originally-declared holder type, and the new value is type-compatible. Null-checks are inserted unless R8 can statically rule out null. If any site doesn't meet the bar, R8 leaves it alone — the optimization is conservative.

Step 3: Dead code cleanup

If every call site was successfully optimized, R8 removes the original updater field and its newUpdater() initialization entirely. If no sites were optimized, R8 removes just the new offset field it introduced. Either way, the class ends up smaller or the same size — never larger.

All three field updater types are covered: AtomicReferenceFieldUpdater, AtomicIntegerFieldUpdater, and AtomicLongFieldUpdater. If your own code uses any of these patterns (common in lock-free queues, custom concurrent data structures), you benefit too — not just coroutines.

The Compose Story: 80% of Modifier.clickable Was Coroutine Overhead

This is the number that genuinely surprised me. The R8 team profiled Modifier.clickable — one of the most commonly composed modifiers in any Compose app — and found that 80% of its execution time was consumed by launching and cancelling coroutines internally. Modifier.clickable uses InteractionSource to track press state, and InteractionSource updates fire through coroutines on every touch event.

What that means in practice: every compose benchmark measuring LaunchedEffect creation and cancellation showed a 2x improvement after AGP 9.2.0. Not 10%, not a rounding error — half the time, gone. For feed UIs like Samachar's news scroll where every visible item has at least one clickable, this is free frame budget back on every touch frame.

If you've been blaming your interaction latency on Compose overhead and spending time tuning remember placement and recomposition scopes — profile again after the AGP bump. Some of what you were measuring was atomic field updater overhead, not composition overhead.

API 36 Bonus: ART Does It Again at the VM Level

On API 36+ devices with a recent ART VM build, the Android Runtime team has implemented a parallel optimization at the VM level. ART detects the same Unsafe intrinsic pattern at runtime and applies ~15% additional acceleration on top of what R8 already did at compile time. You don't need to do anything differently — if a user's device is on API 36, they get the R8 optimization from your compile step plus the ART optimization from the platform.

This is the model the Android performance team has been pushing toward for a few releases: toolchain and platform improvements that compound rather than require developer action. The Baseline Profiles story is similar — you set it up once, the runtime does progressively more with it.

What kotlinx.atomicfu Actually Is

If you haven't worked on coroutines internals, kotlinx.atomicfu might be unfamiliar. It's a JetBrains library that provides a Kotlin-idiomatic wrapper around platform atomic operations — atomic() instead of AtomicReference(), with clean property access syntax rather than get()/set() verbosity.

The library ships a Kotlin compiler plugin that can inline atomic declarations directly into volatile fields, eliminating the wrapper object allocation. So kotlinx.coroutines uses atomicfu with the plugin to get: no allocation from the atomic wrapper, plus AtomicReferenceFieldUpdater semantics under the hood. After the R8 optimization, it also gets: direct Unsafe in the hot path. Three layered optimizations, one library.

// What kotlinx.coroutines does internally (simplified):
internal class ChildHandle(
    private val parent: Job
) : JobNode() {
    // atomicfu tracks this field lock-free, no allocation
    private val _child = atomic<Job?>(null)

    // Every cancel() → atomic CAS → was AtomicReferenceFieldUpdater → now Unsafe
    fun trySet(child: Job) = _child.compareAndSet(null, child)
}

Should You Actually Update to AGP 9.2.0?

The short answer is yes, if you haven't already. AGP 9.2.0 is the current stable alongside AGP 9.3.0 (which shipped last week and adds standalone R8 config analysis and keep-rules-in-source-sets). Both are safe production upgrades for most projects.

A few things to check before bumping:

If you want to use R8 directly without AGP (say, for faster CI iteration), you can grab R8 9.2.0+ as a standalone tool: com.android.tools:r8:9.2.x from the Google Maven repository. The same optimization fires when R8 processes your DEX output, regardless of whether AGP orchestrates the build.

Verifying the Improvement in Your App

Don't take my word for the 2x number — measure it in your own app. If you have a Macrobenchmark target, add a test that repeatedly creates and cancels coroutines in a scenario that matches your app's hot path. The simplest signal is frame timing on your most interactive screens: anything with dense lists of clickable items, swipeable cards, or frequent LaunchedEffect firings.

For Musist specifically, the audio progress ticker and the playlist interaction model both sit on a dense layer of coroutines. For Samachar's infinite scroll feed, every visible item's interaction handling runs through InteractionSource coroutines. Running a scroll benchmark before and after the AGP bump is the fastest way to see whether the 2x on raw ops translates proportionally in your particular composition tree.

The broader pattern here is worth naming: the Android toolchain is now doing the kind of optimization work that used to require developer attention. Baseline Profiles, R8 full mode, AGP's incremental build improvements, now this — each of these is a system-level change that gives you performance for free as long as you stay current with the toolchain. The cost of upgrading AGP has never been lower (the AGP Upgrade Assistant handles most of it), and the compounding benefit of staying on recent versions has never been higher.

Two years ago I'd have told you to profile your atomics manually, consider replacing AtomicReferenceFieldUpdater with VarHandle on API 26+ for better performance, and accept that coroutine overhead was a fixed cost. Now I'd say: update to AGP 9.2.0, re-run your benchmarks, and spend the time you saved on something that isn't the toolchain.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment