You can be on Kotlin 2.x with the “right” Compose compiler plugin in your app module and still see recompositions that feel random. A common cause is mixed Compose compiler versions across modules or prebuilt dependencies, which changes what the compiler can prove as stable/skippable and what the runtime ends up re-executing.
This is avoidable, and it’s testable: you can build a minimal repro, generate compiler reports, and put a small CI check in place so you never merge a change that reintroduces the mismatch.
What changed with Kotlin 2.x (and why this now bites)
Since Kotlin 2.0, the Compose compiler moved into the Kotlin tooling flow: you apply the Compose compiler Gradle plugin and keep its version aligned with Kotlin. Both Kotlin’s docs and Android’s setup guides now steer you towards org.jetbrains.kotlin.plugin.compose and explicitly show the “version matches your Kotlin version” pattern. (developer.android.com)
That’s the straightforward part.
The less obvious part: your app is not compiled as one blob. It is a graph of modules (and external AARs/JARs). If any node in that graph was compiled with an older Compose compiler (or without the Compose compiler at all), you can end up in compatibility paths where the compiler/runtime cannot make the same skipping decisions it would make in a fully aligned world.
JetBrains explicitly call out that Compose compiler behaviour can change across Kotlin versions, including compatibility modes and warnings for older-compiled composable binaries. (github.com)
In practice, this turns into “we didn’t change UI logic, but scrolling got janky” or “this screen started recomposing on every frame” after a dependency bump.
The concrete failure mode: skipping disappears, recomposition explodes
Compose performance hinges on the compiler being able to mark callsites as skippable (and types as stable/unstable), so the runtime can avoid re-running composables when inputs are effectively unchanged. Google’s stability docs are explicit: unstable inputs prevent skipping, and the compiler reports are how you see what it inferred. (developer.android.com)
A compiler mismatch can surface as:
- A library module compiled with an older Compose compiler emits different metadata/wrappers than the consumer expects.
- Types originating from that module are treated more conservatively (effectively “unstable from here”), so downstream composables become non-skippable.
- The recomposition you observe looks random because it’s triggered by normal state reads elsewhere; it’s just that the runtime can no longer skip the expensive parts.
This is one of the nastier categories of Compose performance regressions because the source code diff that “caused it” might simply be “bumped dependency X”.
Minimal reproducible example (two modules, one mismatch)
You want a repro that is small enough to reason about and deterministic enough to run in CI.
Setup
Create a project with:
:ui-lib— an Android library module that defines:- A stable-ish model type (a Kotlin data class with immutable fields).
- A composable
FancyRow(model: Model)that is expected to be skippable whenmodeldoesn’t change. :app— an Android app module that:- Holds
val model by remember { mutableStateOf(Model(...)) } - Calls
FancyRow(model)inside a parent composable that is forced to recompose (for example, reads a tickingLaunchedEffectstate).
Then introduce a mismatch:
- Compile
:appwith Kotlin 2.x and the Kotlin Compose compiler plugin. - Compile
:ui-libwith an older Compose compiler (or misconfigure it so it is not compiled with the same Compose compiler plugin).
The key point is not the exact versions; it’s that the modules are compiled under different rules.
Why this repro works
In the aligned case, FancyRow(model) will usually be eligible to skip when model is equal to its previous value and the call is marked skippable.
In the mismatched case, you often see:
FancyRowbecomes non-skippable in reports.- The parent’s periodic recomposition drags
FancyRowalong for the ride.
You’re not “proving recomposition is random”. You’re proving that a build-time mismatch removes optimisations, and the runtime then behaves exactly as designed.
How to prove it with compiler reports (not vibes)
Both JetBrains and Google now treat Compose compiler reporting as a first-class debugging tool.
Turn on reports in every Compose module
Using the Kotlin Compose compiler Gradle plugin, configure report output per module:
- Apply
org.jetbrains.kotlin.plugin.composeto each module that uses Compose. JetBrains’ migration guide is explicit about applying the plugin “to every module that uses Jetpack Compose”. (kotlinlang.org) - Use the plugin DSL to emit reports into
build/so CI can collect them. Google’s setup guide showscomposeCompiler { reportsDestination = ... }. (developer.android.com)
What artefacts to inspect
From the Android docs on diagnosing stability, the compiler can generate:
<module>-composables.txt<module>-composables.csv
These are designed for scripting: you can search for “skippable” / “restartable” and for particular composable names. (developer.android.com)
For the repro above, you’re looking for a before/after like:
- Before mismatch:
FancyRowis reported asrestartable skippable. - After mismatch: it loses
skippable, or its parameters are inferred unstable.
That’s the evidence you can show in a PR.
Detecting the mismatch from build metadata (what to actually check)
There are two checks worth doing, and they catch different failure modes.
1) Enforce “plugin applied everywhere”
A surprising number of real-world projects have:
- Kotlin 2.x and Compose set up “correctly” in
:app. - Internal feature modules that use Compose but forgot to apply the Compose compiler plugin.
Given JetBrains’ guidance (“apply the plugin to every module that uses Jetpack Compose”), this should be a hard rule. (kotlinlang.org)
CI guardrail:
- Fail if any module that has Compose runtime dependencies (or
buildFeatures.compose = true) is missingorg.jetbrains.kotlin.plugin.compose.
2) Detect older-compiled Compose binaries in your dependency graph
Even if all your modules are aligned, you can pull in third-party artefacts compiled under older Compose compilers.
This is where teams get stuck: “we can’t control external libraries”. True, but you can still:
- Detect them.
- Decide whether to accept the cost, replace the dependency, or isolate it behind a boundary.
Practical approaches:
- Inspect the resolved artefacts on the runtime/compile classpaths and look for Compose compiler markers in metadata.
- Treat a detected mismatch as:
- A warning if the dependency is optional / low-impact.
- A failure if it sits on the hot path of your UI.
JetBrains’ notes on Kotlin/Compose binary compatibility issues and compatibility behaviour underline why this matters: behaviour is not purely source-level; it depends on what the compiler emitted in the producing module. (github.com)
A CI guardrail that actually prevents shipping regressions
A guardrail that works in practice has to meet two constraints:
- It must be deterministic.
- It must be cheap enough to run on every PR.
Here’s a pattern we’ve found robust.
Step-by-step
- Enable Compose compiler reports for every Compose module (as above). (developer.android.com)
- In CI, run a build that compiles all variants you ship (at least
assembleRelease). - Collect all
*-composables.csvoutputs as artefacts. - Run a small script that asserts invariants for your hot-path composables.
What to assert (examples)
Pick a small set of “must not regress” composables: top-level screen scaffolds, your list rows, your item renderers.
Assertions that work:
FancyRowmust remain skippable.ScreenScaffoldmust remain restartable.- No composable in
:design-systemmay have a parameter inferred unstable unless explicitly annotated/justified.
This is not about perfection. It’s about catching the big regressions early, especially those introduced by dependency upgrades.
If you need a policy line in the sand, make it this:
- If a dependency upgrade causes a hot-path composable to lose
skippable, the PR does not merge until you either fix the mismatch or accept the cost explicitly.
Trade-offs and when the right answer is “don’t do this”
A few blunt points that save time:
- If you are adopting Kotlin 2.x, do not keep managing the Compose compiler via legacy Maven coordinates in a piecemeal way. The official guidance is now to use the Compose compiler Gradle plugin aligned to Kotlin, and to remove direct references where possible. (kotlinlang.org)
- Do not accept “random recomposition” as a runtime mystery. Compose gives you compiler reports; use them. (developer.android.com)
- Do not paper over it by marking everything
@Stableor whitelisting external types unless you can prove the type’s immutability. That can buy performance while silently introducing correctness bugs.
Where codeversols can help (briefly)
When this problem shows up in a mid-sized codebase, the hard part is rarely the fix in isolation; it’s building a repeatable discipline around upgrades: Kotlin, AGP, Compose runtime, compiler plugin, and third-party UI dependencies.
At codeversols we do the unglamorous work: align build tooling across multi-module Android apps, instrument Compose performance using compiler reports, and add CI checks so the next upgrade doesn’t reintroduce the same class of regression.
Close
Kotlin 2.x makes Compose compiler alignment more explicit, but it also makes version drift easier to spot if you choose to look. Generate compiler reports, treat skippability as a contract for hot UI paths, and enforce it in CI. Once you do, “random recomposition” stops being a spooky runtime story and becomes a build-time failure you can fix before it ships.



