Mobile Engineering

Kotlin 2 + Compose compiler mismatch: the dependency trap that causes ‘random’ recomposition (and how to prove it in CI)

Mixed Compose compiler versions across modules and prebuilt dependencies can quietly disable skipping and inflate recompositions. Here’s a minimal repro, how to surface it from build artefacts, and a CI guardrail to stop regressions shipping.

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 when model doesn’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 ticking LaunchedEffect state).

Then introduce a mismatch:

  • Compile :app with Kotlin 2.x and the Kotlin Compose compiler plugin.
  • Compile :ui-lib with 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:

  • FancyRow becomes non-skippable in reports.
  • The parent’s periodic recomposition drags FancyRow along 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.compose to 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 shows composeCompiler { 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: FancyRow is reported as restartable 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 missing org.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

  1. Enable Compose compiler reports for every Compose module (as above). (developer.android.com)
  2. In CI, run a build that compiles all variants you ship (at least assembleRelease).
  3. Collect all *-composables.csv outputs as artefacts.
  4. 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:

  • FancyRow must remain skippable.
  • ScreenScaffold must remain restartable.
  • No composable in :design-system may 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 @Stable or 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.

More From Our Blog

Android 16 edge-to-edge is enforced: the one screenshot you should diff in CI (and how to stop OEM inset regressions)
Mobile Engineering • Oct 19, 2026

Android 16 edge-to-edge is enforced: the one screenshot you should diff in CI (and how to stop OEM inset regressions)

Targeting Android 16 (API 36) removes the last edge-to-edge opt-out, so any latent inset assumptions become production bugs. Here’s a pragmatic CI gate: one golden screenshot with an insets overlay that catches IME, cutout and OEM window-insets drift before you ship.

Read more →
Swift Observation performance: auditing registrar contention when your model is fast but your dependencies aren’t
Mobile Engineering • Oct 18, 2026

Swift Observation performance: auditing registrar contention when your model is fast but your dependencies aren’t

Swift Observation can be plenty fast in isolation, yet still trigger stutters when app-level patterns cause high-frequency invalidations and lock contention. Here’s a profiler-first audit you can run in mixed SwiftUI/UIKit apps, plus the three refactors that reliably reduce contention and update fan-out.

Read more →
Swift Observation performance improvements: audit over-invalidation and unobserved writes before the next toolchain drop
Mobile Engineering • Oct 17, 2026

Swift Observation performance improvements: audit over-invalidation and unobserved writes before the next toolchain drop

Swift Observation is getting active performance/model-shape work again. This audit shows what to measure now—over-invalidation and unobserved writes—so you can prove gains later and avoid new regressions.

Read more →