Back to Blog

Performance & Profiling: Recomposition Optimization with Live Metrics | Kotlin Desktop #19

Sandy LaneSandy Lane

Video: Performance & Profiling: Recomposition Optimization with Live Metrics | Kotlin Desktop #19 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Performance and Profiling: Make Compose Fast

Recomposition tracking, @Immutable, derivedStateOf, key {}, stable callbacks. Five fixes that turn a sluggish list into a responsive one — measured on the same code, side by side.

Today's app is a "performance lab" — two product lists rendering the same data. The naïve list recomposes hundreds of times when nothing visibly changes; the optimised list recomposes only when needed. The blog walks through the five fixes that close the gap.

What we are building

A 1100×700 window with a left/right split:

  • Left: the naïve list — five known anti-patterns.
  • Right: the optimised list — same UI, fixed.
  • Top bar: total recomposition counts for each side (live).
  • Bottom panel: a RecompositionTracker table that counts recompositions by Composable name.

Run the app, type in the search box, click "favourite" buttons — watch the recomposition counts diverge. The optimised side stays low; the naïve side explodes.

RecompositionTracker

object RecompositionTracker {
  private val counts = ConcurrentHashMap<String, AtomicInteger>()

  fun track(name: String) {
    counts.getOrPut(name) { AtomicInteger(0) }.incrementAndGet()
  }

  fun getAllCounts(): Map<String, Int> = counts.mapValues { it.value.get() }
  fun reset() = counts.clear()
}

@NonRestartableComposable
@Composable
fun TrackRecomposition(name: String) {
  SideEffect {
    RecompositionTracker.track(name)
  }
}

SideEffect runs after every successful composition — perfect for counting. @NonRestartableComposable tells the compiler "this composable's output doesn't depend on its inputs in the way Compose normally tracks," so it doesn't cache or skip. Drop a TrackRecomposition("MyCard-${id}") into any composable and you can count its recompositions.

For production-grade tooling, use Android Studio's Compose Layout Inspector — but a 30-line tracker is enough to debug local hotspots.

Fix 1: @Immutable data class

data class Product(
  val id: Int, val name: String, val category: String,
  val price: Double, val rating: Double,
  var isFavorite: Boolean = false  // <-- var = NOT immutable
)

@Immutable
data class StableProduct(
  val id: Int, val name: String, val category: String,
  val price: Double, val rating: Double,
  val isFavorite: Boolean = false  // val = immutable
)

Compose decides whether to skip a Composable's recomposition by looking at its parameters. If a parameter has unstable type, Compose conservatively re-runs. data class Product(var ...) is unstable because the var field could change without telling Compose.

Two fixes:

  • Make all fields val. Compose sees the type is stable.
  • Annotate with @Immutable. Promises Compose "this never changes after construction."

StableProduct is both. Now OptimizedProductCard(product = stableProduct) skips recomposition when the same StableProduct is passed again.

Fix 2: derivedStateOf for filter+sort

val filteredProducts by remember(products) {
  derivedStateOf {
    products.filter { it.name.contains(searchQuery, ignoreCase = true) }
      .let { filtered ->
        when (sortOrder) {
          SortOrder.NAME_ASC -> filtered.sortedBy { it.name }
          SortOrder.PRICE_ASC -> filtered.sortedBy { it.price }
          // ...
        }
      }
  }
}

derivedStateOf wraps a computation that reads multiple state values. The result only recomposes when the output changes — even if the input states recompose more often.

Compare to a plain remember:

val filteredProducts = remember(products, searchQuery, sortOrder) { /* ... */ }

remember(keys) recomputes when any key changes. derivedStateOf is similar but with one critical difference: it caches the last value and skips recompose if the new computation produces an equal result. For sort/filter results that often produce the same output for different intermediate inputs, this matters.

Use derivedStateOf when:

  • You're computing a value from multiple state pieces.
  • The output is expensive to recompute.
  • The output often doesn't change even when inputs do.

Fix 3: items(key = { id })

LazyColumn {
  items(
    count = filteredProducts.size,
    key = { filteredProducts[it].id },
  ) { index ->
    OptimizedProductCard(product = filteredProducts[index])
  }
}

Without a key, LazyColumn identifies items by position. Sort the list, item 5 is now at position 2 → Compose thinks "the thing at position 2 changed completely" and recomposes from scratch.

key = { item.id } makes Compose track items by identity. After a sort, item 5 is still item 5; it just moved. Compose moves the existing composition rather than re-creating it. Animations preserve, scroll position preserves, and recompositions drop dramatically.

Always supply a key for items whose order can change.

Fix 4: Stable callback signature

// BAD: lambda allocates a new instance every recomposition
items(products) { product ->
  ProductCard(product = product, onFavorite = { /* uses product directly */ })
}

// GOOD: callback receives ID; same lambda instance every time
val onFavorite = remember<(Int) -> Unit> {
  { id -> /* ... */ }
}
items(products, key = { it.id }) { product ->
  OptimizedProductCard(product = product, onFavorite = onFavorite)
}

Two changes:

  1. onFavorite: (Int) -> Unit instead of () -> Unit. The lambda doesn't capture product, so it doesn't change with the row. Compose sees the same lambda reference across recompositions.
  2. remember { ... } around the lambda. Without it, even a non-capturing lambda is a new instance every recomposition. remember caches it.

Lambda equality is reference equality. Two lambdas that "look the same" are not equal — Compose can't tell, and assumes the parameter changed.

Fix 5: remember for derived strings

val priceText = remember(product.price) {
  "$${String.format("%.2f", product.price)}"
}

String formatting is cheap, but it allocates a new String every time. In a list of 50 cards, that's 50 allocations per recomposition. remember(price) caches the result and only recomputes when the price actually changes.

The pattern generalises: any non-trivial computation in a Composable body is a candidate for remember.

Isolation: TickerDisplay in its own composable

@Composable
private fun TickerDisplay(tickCount: Int) {
  TrackRecomposition("TickerDisplay")
  Text("Tick: $tickCount")
}

A 500ms tick increments tickCount. If the parent reads tickCount directly, the entire parent recomposes every 500ms — including 50 product cards.

By extracting the tick display into its own Composable that takes tickCount as a parameter, only TickerDisplay reads it. The parent doesn't recompose when the tick changes; only the small Text inside TickerDisplay does.

Rule: keep state reads close to where the state is used. The further up the tree a state is read, the more recomposes when it changes.

What "stability" means to the compiler

Compose's compiler plugin classifies types:

  • Stable — no fields change after construction, or all fields are themselves stable. Compose can compare-by-equality.
  • Unstable — has mutable fields, or contains a type the compiler can't analyse (e.g., a non-data class from another module).

Stable types let Compose skip recompositions when their fields are equal to last frame's. Unstable types force re-running.

Make types stable by:

  • Using data class with all val fields, all stable types.
  • Adding @Immutable when you guarantee no field will ever change.
  • Adding @Stable when fields can change but you'll notify via mutableStateOf (rare).

List<T> is unstable by default — the compiler can't tell if a List is read-only. Use kotlinx.collections.immutable.ImmutableList for guaranteed-stable lists.

Profiling: Compose Compiler Reports

Add to build.gradle.kts:

kotlin {
  compilerOptions {
    freeCompilerArgs.addAll(
      "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
        layout.buildDirectory.dir("compose_reports").get().asFile.absolutePath,
    )
  }
}

Build, then read build/compose_reports/. Each Composable is annotated restartable skippable (good), restartable (only re-runs sometimes), or restartable not skippable (always re-runs — investigate). The reports tell you exactly which params are unstable.

This is the canonical way to debug Compose performance — better than guessing.

Common mistakes

Adding @Stable to types with mutable fields. Lying to the compiler causes incorrect skips. Use @Immutable only when truly immutable.

Using remember for state, not computation. mutableStateOf for state; remember for caching computations or instances. Mixing them creates bugs.

Putting derivedStateOf outside remember. It must be inside remember, otherwise it's recreated every recomposition (defeating the purpose).

Tracking recompositions inside production builds. TrackRecomposition adds overhead. Wrap in a debug flag or remove before shipping.

Optimising before measuring. Add the tracker, find the actual hotspot, fix only that. Premature optimisation in Compose often regresses.

What's next

Lesson 20: Pomodoro Timer + DMG Packaging. A polished 25-minute focus timer with a Canvas-drawn ring, plus building and signing a native macOS .dmg for distribution. The bridge from "code on my machine" to "ships to users."

Recap

Five fixes for compose performance: (1) @Immutable data class with all val fields, (2) derivedStateOf for compound state, (3) key = { it.id } on items, (4) remember lambdas with stable signatures, (5) remember(value) for cached derivations. Plus: isolate hot state reads in their own Composables. Use TrackRecomposition and the compiler reports to measure before optimising.

Next lesson: Pomodoro timer and DMG packaging.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.