Compose is fast by default — but it’s also easy to accidentally make it slow. A composable that recomposes 60 times per second during a scroll, an unstable parameter that prevents skipping, a lambda recreated on every recomposition — these performance traps are invisible until your UI starts dropping frames. This guide covers how Compose performance works internally, how to identify problems, and the concrete patterns that keep your app running at 60fps.


How Compose Performance Works

// Compose has THREE strategies for performance:
//
// 1. SMART RECOMPOSITION — only recompose composables whose inputs changed
//    If a composable's parameters are the same → SKIP it entirely
//
// 2. SINGLE-PASS LAYOUT — unlike the View system's multi-pass measure,
//    Compose measures each node exactly ONCE per frame
//    No double-taxation from nested layouts
//
// 3. LAZY COMPOSITION — LazyColumn/LazyRow only compose VISIBLE items
//    Items off-screen are not composed, measured, or drawn
//
// Your job: don't break these optimisations
// Most performance issues come from accidentally defeating smart recomposition

The #1 Performance Concept: Skipping

// When a parent recomposes, Compose checks each child:
// "Have your inputs changed since last time?"
// If NO → SKIP (don't re-call the function — huge performance win)
// If YES → RECOMPOSE (re-call the function with new inputs)

// For skipping to work, Compose must be able to COMPARE inputs
// This requires inputs to be STABLE (explained below)

// ═══ SKIPPING IN ACTION ═════════════════════════════════════════════
//
//  Parent recomposes (count changed from 5 to 6):
//
//  @Composable fun Parent() {
//      var count by remember { mutableIntStateOf(5) }
//
//      CountDisplay(count)   → count changed (5→6) → RECOMPOSE ✅
//      UserAvatar(user)      → user unchanged      → SKIP ⏭️
//      StaticFooter()        → no params            → SKIP ⏭️
//      ArticleList(articles) → articles unchanged?  → depends on stability!
//  }
//
//  If ArticleList takes List<Article> (unstable) → RECOMPOSES even if unchanged
//  If ArticleList takes ImmutableList<Article> (stable) → SKIPS correctly

Stability — The Key to Skipping

// A type is STABLE when Compose can reliably determine:
// "Are these two instances equal?"
//
// If Compose can't determine equality → it assumes "changed" → recomposes
// If Compose CAN determine equality → it compares → skips if equal

// ═══ STABLE TYPES (Compose can compare) ══════════════════════════════
// ✅ Primitives: Int, Long, Float, Double, Boolean, String
// ✅ Enum classes
// ✅ Function types (lambda references)
// ✅ Compose State types: State<T>, MutableState<T>
// ✅ Data classes where ALL properties are stable
// ✅ Classes annotated with @Immutable
// ✅ Classes annotated with @Stable
// ✅ ImmutableList, ImmutableMap, ImmutableSet (from kotlinx-collections-immutable)

// ═══ UNSTABLE TYPES (Compose assumes "changed") ═════════════════════
// ❌ List, MutableList, ArrayList
// ❌ Map, MutableMap, HashMap
// ❌ Set, MutableSet, HashSet
// ❌ Classes from external libraries (Compose can't verify their equals)
// ❌ Data classes with ANY unstable property
// ❌ Interfaces (implementations might be unstable)
// ❌ var properties (mutable without Compose observation)

// WHY is List unstable?
// val items: List<String> = mutableListOf("A", "B")
// Someone could cast to MutableList and call .add("C")
// The reference is the same but contents changed
// Compose can't trust equals() → marks as unstable → never skips

How unstable parameters kill performance

// ❌ UNSTABLE — ArticleCard can NEVER be skipped
data class Article(
    val id: String,                 // ✅ stable
    val title: String,              // ✅ stable
    val tags: List<String>          // ❌ UNSTABLE → makes entire class unstable
)

@Composable
fun ArticleList(articles: List<Article>) {
    // List<Article> is unstable — this composable ALWAYS recomposes
    // Even if the list hasn't changed!
    LazyColumn {
        items(articles, key = { it.id }) { article ->
            ArticleCard(article = article)
            // Article is unstable → ArticleCard ALWAYS recomposes
            // Even if this specific article hasn't changed!
        }
    }
}

// On a list of 100 articles:
// Parent recomposes → ArticleList recomposes → ALL 100 ArticleCards recompose
// Even though maybe only 1 article changed → 99 unnecessary recompositions!

Fixing stability

// FIX 1: Use ImmutableList from kotlinx-collections-immutable
// implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")

import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
// ImmutableList is an INTERFACE annotated @Stable
// persistentListOf() is a TOP-LEVEL FUNCTION that creates an ImmutableList

data class Article(
    val id: String,
    val title: String,
    val tags: ImmutableList<String>   // ✅ STABLE — ImmutableList is @Stable
)

@Composable
fun ArticleList(articles: ImmutableList<Article>) {
    // ImmutableList<Article> is stable → skipping works correctly
    LazyColumn {
        items(articles, key = { it.id }) { article ->
            ArticleCard(article = article)
            // Article is now stable → ArticleCard SKIPS when article hasn't changed
        }
    }
}

// Create immutable lists:
val articles = persistentListOf(article1, article2, article3)
// Or convert: articles.toPersistentList()
// toPersistentList() is an EXTENSION FUNCTION on Iterable


// FIX 2: Annotate with @Immutable (promise that the class never changes)
@Immutable
// @Immutable is an ANNOTATION from compose.runtime
data class Article(
    val id: String,
    val title: String,
    val tags: List<String>   // you PROMISE not to mutate this list
)
// ⚠️ This is a CONTRACT — if you mutate the list, Compose won't know → stale UI


// FIX 3: Annotate with @Stable (promise that changes are observable)
@Stable
// @Stable is an ANNOTATION from compose.runtime
class ArticleState(
    val id: String,
    title: String,
    isBookmarked: Boolean
) {
    var title by mutableStateOf(title)
    // Changes are tracked by Compose State → @Stable is truthful
    var isBookmarked by mutableStateOf(isBookmarked)
}

Strong Skipping Mode

// Strong Skipping Mode is a COMPOSE COMPILER FEATURE (default since Compose Compiler 2.0)
// It makes skipping work even for unstable parameters
//
// Without Strong Skipping:
//   Unstable param → composable can NEVER be skipped
//
// With Strong Skipping:
//   Unstable param → Compose uses REFERENCE EQUALITY (===) instead of equals()
//   If it's the SAME object reference → SKIP
//   If it's a DIFFERENT reference → RECOMPOSE
//
// This means List<String> with the same reference WILL be skipped
// But a new List<String> with the same contents WON'T be skipped
// (because it's a different reference even though equals() would return true)

// Strong Skipping also:
// - Automatically wraps unstable lambdas in remember { }
// - Makes ALL composable functions restartable AND skippable by default

// Check if Strong Skipping is enabled (it should be by default):
// Compose Compiler 2.0+ has it on by default
// If using an older compiler, enable explicitly:
// composeCompiler {
//     enableStrongSkippingMode = true
// }

// Even with Strong Skipping, using stable types is STILL better:
// @Immutable + ImmutableList → skips on VALUE equality (correct)
// Strong Skipping with List → skips on REFERENCE equality (weaker)
// A new list with same contents would be skipped with @Immutable but not with Strong Skipping

Lambda Stability

// Lambdas passed to composables affect skipping
// A NEW lambda instance every recomposition → child can't be skipped

// ❌ NEW lambda every recomposition — child always recomposes
@Composable
fun Parent(viewModel: MyViewModel) {
    ArticleList(
        onItemClick = { id -> viewModel.onItemClicked(id) }
        // New lambda OBJECT created every time Parent recomposes
        // ArticleList sees a "different" onItemClick → recomposes
    )
}

// ✅ FIX 1: Method reference — same instance across recompositions
ArticleList(onItemClick = viewModel::onItemClicked)
// :: creates a METHOD REFERENCE — stable, same object every time

// ✅ FIX 2: remember the lambda
val onItemClick = remember(viewModel) {
    { id: String -> viewModel.onItemClicked(id) }
}
ArticleList(onItemClick = onItemClick)

// ✅ FIX 3 (automatic): Strong Skipping Mode wraps lambdas in remember
// If Strong Skipping is enabled, the compiler automatically
// wraps unstable lambdas in remember { } for you
// So the ❌ example above actually works fine with Strong Skipping!

// When to still worry about lambdas:
// - Performance-critical scrolling lists (LazyColumn items)
// - Composables called thousands of times per frame
// - If you've disabled Strong Skipping for some reason

Deferring State Reads

Compose has three phases: Composition → Layout → Drawing. Reading state in an earlier phase triggers more work than reading it in a later phase:

// ═══ PHASE COMPARISON ════════════════════════════════════════════════
//
// State read in COMPOSITION → triggers: Composition + Layout + Drawing
// State read in LAYOUT      → triggers:              Layout + Drawing
// State read in DRAWING     → triggers:                       Drawing
//
// Read state in the LATEST possible phase to minimise work

// EXAMPLE: Scroll-dependent alpha

// ❌ Reading in Composition — recomposes on EVERY scroll pixel
@Composable
fun Header(scrollOffset: Float) {
    val alpha = (1f - scrollOffset / 500f).coerceIn(0f, 1f)
    // scrollOffset is READ during Composition
    // Every pixel of scroll → recomposition → layout → draw
    Text("Header", modifier = Modifier.alpha(alpha))
}

// ✅ Reading in Drawing — only redraws, skips composition and layout
@Composable
fun Header(scrollOffsetProvider: () -> Float) {
    // scrollOffsetProvider is a LAMBDA — not read during Composition
    Text("Header", modifier = Modifier.graphicsLayer {
        // graphicsLayer { } is an EXTENSION FUNCTION on Modifier
        // The lambda runs during the DRAWING phase
        alpha = (1f - scrollOffsetProvider() / 500f).coerceIn(0f, 1f)
        // scrollOffsetProvider() is called during Drawing, not Composition
        // Only triggers redraw — no recomposition, no re-layout
    })
}

// EXAMPLE: Scroll-dependent offset

// ❌ Composition phase — full recomposition on every scroll pixel
@Composable
fun ParallaxImage(scrollOffset: Int) {
    Image(
        modifier = Modifier.offset(y = (scrollOffset * 0.5f).dp)
        // offset(y = Dp) reads value during COMPOSITION
    )
}

// ✅ Layout phase — skips recomposition, only re-layouts
@Composable
fun ParallaxImage(scrollOffsetProvider: () -> Int) {
    Image(
        modifier = Modifier.offset {
            // offset { } LAMBDA version reads during LAYOUT phase
            IntOffset(0, (scrollOffsetProvider() * 0.5f).toInt())
        }
    )
}

Lambda modifier variants

// Many Modifier functions have TWO versions:
// 1. Value version → reads in Composition
// 2. Lambda version → reads in Layout or Drawing

// OFFSET
Modifier.offset(x = 10.dp, y = 20.dp)          // Composition phase
Modifier.offset { IntOffset(x, y) }              // Layout phase ✅

// ALPHA
Modifier.alpha(0.5f)                              // Composition phase
Modifier.graphicsLayer { alpha = 0.5f }           // Drawing phase ✅

// PADDING — no lambda variant
// Use Modifier.layout { } for dynamic padding in Layout phase

// BACKGROUND — no lambda variant
// Use Modifier.drawBehind { } for dynamic backgrounds in Drawing phase
Modifier.drawBehind {
    // drawBehind { } is an EXTENSION FUNCTION on Modifier
    // The lambda receives DrawScope — runs during Drawing phase
    drawRect(color = dynamicColor)
}

// Rule of thumb:
// If the value changes every frame (scroll, animation) → use lambda variant
// If the value changes rarely (state toggle) → value variant is fine

derivedStateOf — Reducing Recomposition Frequency

// derivedStateOf converts FREQUENT state changes into INFREQUENT ones
// The derived value only triggers recomposition when IT changes, not when the source changes

// ❌ Without derivedStateOf — recomposes on EVERY scroll pixel
@Composable
fun ScrollScreen() {
    val listState = rememberLazyListState()
    // listState.firstVisibleItemIndex changes on every scroll movement

    val showButton = listState.firstVisibleItemIndex > 0
    // This expression is evaluated during recomposition
    // firstVisibleItemIndex changes from 0→1→2→3→4...
    // showButton only changes TWICE: false→true and true→false
    // But the composable recomposes for EVERY index change!

    if (showButton) {
        ScrollToTopButton()
    }
}

// ✅ With derivedStateOf — recomposes only when the BOOLEAN changes
@Composable
fun ScrollScreen() {
    val listState = rememberLazyListState()

    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
        // derivedStateOf is a TOP-LEVEL FUNCTION from compose.runtime
        // Returns State<Boolean>
        // Re-evaluates when firstVisibleItemIndex changes
        // BUT only triggers recomposition when the RESULT (Boolean) changes
        // Index 0→1: false→true → recomposition ✅
        // Index 1→2: true→true → NO recomposition (result didn't change) ⏭️
        // Index 2→3: true→true → NO recomposition ⏭️
    }

    if (showButton) {
        ScrollToTopButton()
    }
}

// When to use derivedStateOf:
// ✅ Converting rapidly-changing state to a less-frequent derived value
//    scrollOffset → showHeader (boolean), scrollPercent → category (String)
// ❌ Simple transformations of already-infrequent state
//    count → "Count: $count" — no benefit, recomposition frequency is the same

LazyColumn Performance

// 1. ALWAYS provide keys — enables efficient item reuse
// ❌ No keys — items tracked by position, breaks on reorder
LazyColumn {
    items(articles) { ArticleCard(it) }
}

// ✅ Keys — items tracked by identity, correct recomposition
LazyColumn {
    items(articles, key = { it.id }) { ArticleCard(it) }
}

// 2. Avoid heavy composition in item composables
// ❌ Complex state creation inside each item
items(articles, key = { it.id }) { article ->
    val formattedDate = remember(article.date) {
        SimpleDateFormat("MMM dd", Locale.getDefault()).format(article.date)
    }
    // Better: pre-compute formattedDate in the data class or ViewModel
}

// 3. Use contentType for heterogeneous lists
LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { item ->
            // contentType tells Compose which items can SHARE ViewHolders
            // Items with the same contentType reuse each other's compositions
            when (item) {
                is FeedItem.Article -> "article"
                is FeedItem.Ad -> "ad"
                is FeedItem.Header -> "header"
            }
        }
    ) { item ->
        when (item) {
            is FeedItem.Article -> ArticleCard(item)
            is FeedItem.Ad -> AdBanner(item)
            is FeedItem.Header -> SectionHeader(item)
        }
    }
}

// 4. Avoid nesting LazyColumn — use item {} for mixed content
// ❌ Nested scrollable containers crash
// ✅ Single LazyColumn with item {} and items {} for everything

Compose Compiler Reports — Finding Problems

// The Compose Compiler can generate REPORTS showing:
// - Which classes are stable/unstable
// - Which composables are skippable/restartable
// - Which parameters are stable/unstable

// Enable in build.gradle.kts:
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    // reportsDestination is a PROPERTY on ComposeCompilerExtension
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

// Run: ./gradlew assembleRelease

// Check generated files in app/build/compose_reports/:
// - *-classes.txt     → stability of each class
// - *-composables.txt → restartable/skippable status of each composable
// - *-module.json     → summary metrics

// Example output from composables.txt:
// restartable skippable scheme("[...]") fun ArticleCard(
//   stable article: Article
//   stable onClick: Function0<Unit>
// )
// → "skippable" = Compose CAN skip this composable ✅

// restartable scheme("[...]") fun FeedList(
//   unstable articles: List<Article>
// )
// → NOT "skippable" = Compose can NEVER skip this ❌
// Fix: change List to ImmutableList

// Example output from classes.txt:
// stable class Article {
//   stable val id: String
//   stable val title: String
//   unstable val tags: List<String>   ← makes entire class unstable!
// }
// Fix: change to ImmutableList<String>

Baseline Profiles

// Baseline Profiles tell ART which code paths to compile AHEAD OF TIME
// This dramatically improves app startup and first-frame rendering

// Without Baseline Profile:
// App starts → ART interprets code → JIT compiles hot paths over time
// First few seconds are slow (not compiled yet)

// With Baseline Profile:
// App installs → ART pre-compiles critical paths from the profile
// App starts → critical code is already native → fast startup

// Setup:
// 1. Add the Baseline Profile Gradle plugin
// plugins { id("androidx.baselineprofile") }

// 2. Create a BaselineProfileGenerator test:
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule
    val rule = BaselineProfileRule()
    // BaselineProfileRule is a CLASS from benchmark.macro.junit4

    @Test
    fun generateProfile() {
        rule.collect(packageName = "com.example.myapp") {
            // Launch the app and perform typical user journeys
            pressHome()
            startActivityAndWait()

            // Navigate through critical screens
            device.findObject(By.text("Articles")).click()
            device.waitForIdle()
            device.findObject(By.text("Settings")).click()
            device.waitForIdle()
        }
    }
}

// 3. Generate: ./gradlew :app:generateBaselineProfile
// 4. Profile is automatically included in your release build

// Impact: typically 15-40% faster app startup
// All Compose apps should have a Baseline Profile

Performance Checklist

// ✅ Use STABLE types for composable parameters
//    ImmutableList instead of List
//    @Immutable or @Stable annotations where needed
//    Data classes with all stable properties

// ✅ Use KEYS in LazyColumn/LazyRow items
//    items(list, key = { it.id }) { }

// ✅ DEFER state reads to later phases for animation/scroll
//    Modifier.graphicsLayer { } instead of Modifier.alpha()
//    Modifier.offset { } instead of Modifier.offset()

// ✅ Use derivedStateOf for frequent→infrequent state conversions
//    scrollOffset → showButton (Boolean)

// ✅ Use method references for lambda parameters
//    viewModel::onItemClicked instead of { viewModel.onItemClicked(it) }

// ✅ EXTRACT composables into separate functions for independent recomposition scopes

// ✅ Use remember(key) for expensive calculations
//    remember(articles) { articles.filter { it.isPublished } }

// ✅ Use collectAsStateWithLifecycle (not collectAsState)
//    Stops collecting when app is in background

// ✅ Generate Compose Compiler Reports to find unstable classes

// ✅ Add a Baseline Profile for faster startup

// ✅ Use contentType in LazyColumn for heterogeneous lists

// ❌ AVOID allocating objects inside composable functions
//    No "val paint = Paint()" inside onDraw/drawBehind
//    No "val list = listOf(...)" that creates new instances

// ❌ AVOID reading state at a broader scope than needed
//    Read scroll state only in the composable that uses it

// ❌ AVOID nesting LazyColumn inside scrollable containers

Common Mistakes to Avoid

Mistake 1: Using List instead of ImmutableList

// ❌ List is unstable — composable can never be skipped
@Composable
fun TagBar(tags: List<String>) { /* always recomposes */ }

// ✅ ImmutableList is stable — skipping works
@Composable
fun TagBar(tags: ImmutableList<String>) { /* skips when tags unchanged */ }

Mistake 2: Reading scroll state in Composition phase

// ❌ Recomposes on every scroll pixel
val alpha = scrollState.value / 500f
Text(modifier = Modifier.alpha(alpha))

// ✅ Read in Drawing phase — only redraws
Text(modifier = Modifier.graphicsLayer {
    alpha = scrollState.value / 500f
})

Mistake 3: Not extracting composables

// ❌ One big composable — everything recomposes together
@Composable
fun Screen() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count")
    ExpensiveChart(data)     // recomposes unnecessarily!
    HeavyImageGallery()      // recomposes unnecessarily!
}

// ✅ Extracted — independent recomposition scopes
@Composable
fun Screen() {
    var count by remember { mutableIntStateOf(0) }
    CountDisplay(count)      // recomposes when count changes
    ExpensiveChart(data)     // SKIPPED — data unchanged
    HeavyImageGallery()      // SKIPPED — no params
}

Mistake 4: Missing keys in LazyColumn

// ❌ No keys — items tracked by position, inefficient on data changes
items(articles) { ArticleCard(it) }

// ✅ Keys — Compose knows which item is which, efficient updates
items(articles, key = { it.id }) { ArticleCard(it) }

Mistake 5: Over-optimising before measuring

// ❌ Adding @Immutable, derivedStateOf, graphicsLayer everywhere "just in case"
// Extra complexity with no measurable benefit

// ✅ Measure first, optimise second:
// 1. Run the app — does it feel smooth?
// 2. Check Layout Inspector — are recomposition counts high?
// 3. Generate Compose Compiler Reports — which classes are unstable?
// 4. Fix the SPECIFIC problems found
// 5. Benchmark to confirm improvement

// Don't optimise what isn't slow

Summary

  • Compose performance relies on smart recomposition (skipping unchanged composables) and single-pass layout
  • Skipping requires all parameters to be stable — Compose compares old vs new, skips if equal
  • Stable types: primitives, String, enums, @Immutable classes, @Stable classes, ImmutableList/ImmutableMap
  • Unstable types: List, Map, Set (mutable collections), classes with unstable properties
  • Fix stability with ImmutableList (interface from kotlinx-collections-immutable), @Immutable (annotation), or @Stable (annotation)
  • Strong Skipping Mode (Compose Compiler 2.0+ default) enables skipping for unstable params via reference equality and auto-remembers lambdas
  • Use method references (viewModel::method) for lambda params to avoid creating new instances
  • Defer state reads to later phases: graphicsLayer { } (Drawing), offset { } (Layout) instead of direct value reads (Composition)
  • derivedStateOf { } (top-level function) converts frequent state changes to infrequent derived values — reduces recomposition
  • Always provide keys and contentType in LazyColumn/LazyRow items
  • Extract composables into separate functions to create independent recomposition scopes
  • Use Compose Compiler Reports to find unstable classes and non-skippable composables
  • Add a Baseline Profile for 15-40% faster app startup
  • Use collectAsStateWithLifecycle() (extension function on StateFlow) to stop collecting in background
  • Measure before optimising — Layout Inspector and Compiler Reports show real problems

Compose performance comes down to one principle: help Compose skip as much work as possible. Use stable types so skipping works. Defer state reads so fewer phases run. Extract composables so recomposition scopes are small. And always measure before optimising — the Compose Compiler Reports tell you exactly what’s stable, what’s skippable, and what needs fixing.

Happy coding!