---
title: 'Coroutines, Reflection & R8'
excerpt: 'R8 now compiles away the reflection tax hiding inside every coroutine launch.'
date: 2026-07-29
categories: android
featureImage: '/images/the-reflection-tax.webp'

prev:
  text: 'Less Env, More Config'
  link: '/blog/less-env-more-config'

next:
  text: 'WebFontKit: Google Fonts'
  link: '/blog/webfontkit'
---

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](https://android-developers.googleblog.com/2026/07/how-r8-made-kotlin-coroutines-2x-faster.html),
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 `Job`s - 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:

```java
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:

1. **Instrument** - add an offset constant next to the updater field. That's what `Atomic*FieldUpdater` holds internally
   anyway, minus the validation. The original field stays for now, which is what allows *partial* optimization.

2. **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:

   ```java
   SyntheticUnsafe.UNSAFE.compareAndSwapObject(
       holder, Example.updater$offset, expected, newValue
   );
   ```

3. **Clean up** - drop whichever field went unused. Trickier than it sounds: `newUpdater` and `getDeclaredField` can
   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?

> [!TIP] 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.

> [!IMPORTANT] But... it's nanoseconds?
> One launch is a few
> `tooltip(text: CAS, content: Compare-And-Swap - atomically update a value only if it still holds the expected one.)`
> 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.

---

### References

1. <https://android-developers.googleblog.com/2026/07/how-r8-made-kotlin-coroutines-2x-faster.html>
2. <https://github.com/Kotlin/kotlinx.coroutines/issues/3950>
3. <https://shipilev.net/blog/2015/faster-atomic-fu/>
