Jetpack Compose Performance Optimization: Stop Burning Your 16ms Frame Budget

AppBooster Team · · 10 min read
Android developer analyzing performance metrics on a monitor

Your Compose app looks perfect in the emulator. Sixty frames per second, buttery smooth, demo-ready. Then you hand it to a mid-range device running Android 10 and watch it stutter through a simple list scroll.

That gap — between emulator and reality — is where most Compose performance problems live. And the root cause is almost always the same: unnecessary recomposition.

Compose’s reactive model is its superpower. But if you’re not deliberate about how state flows through your UI tree, you’ll trigger full subtree recompositions dozens of times per second, blowing your 16ms frame budget on work the framework didn’t need to do.

This post covers the optimizations that actually move the needle — with code you can apply today.


The 16ms Budget and Why Compose Eats It

Every frame on a 60Hz display has exactly 16.67ms to complete three phases:

  1. Composition — Execute composable functions, build the UI tree
  2. Layout — Measure and place every node
  3. Drawing — Render to the screen

Miss that window and you drop a frame. Drop enough frames and users feel it as jank — even if they can’t articulate why the app “feels slow.”

Compose is smart about skipping composables whose inputs haven’t changed. The keyword is smart, not perfect. When you hand it unstable types, impure lambdas, or poorly scoped state reads, it falls back to recomposing everything. That’s where your 16ms goes.

Jetpack Compose rendering pipeline diagram showing three phases


1. Understand What Triggers Recomposition

Before optimizing, you need to see the problem. Enable the Layout Inspector’s recomposition count overlay in Android Studio, or add this to your debug builds:

// build.gradle.kts
android {
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.8"
    }
}

Then watch the recomposition counts as you interact with your UI. Any composable recomposing more than once per user action is a candidate for optimization.

The three most common culprits:

  • Unstable lambda captures that change on every recomposition
  • Reading state too high in the tree, pulling large subtrees into recomposition scope
  • Passing List, Set, or Map — Compose treats all of these as unstable by default

That last one surprises most developers. A List<String> passed as a parameter will cause Compose to skip its stability check and always recompose. Always.


2. Fix Unstable Types with @Stable and @Immutable

Compose’s compiler tracks whether a type is “stable” — meaning its equals() implementation is reliable and its public fields are either val or themselves stable. If a type fails that check, composables that receive it can’t be skipped.

The problem:

// Compose treats List as unstable — this composable can't be skipped
@Composable
fun UserList(users: List<User>) {
    users.forEach { UserItem(it) }
}

The fix — wrap in a stable holder:

@Immutable
data class UserList(val items: List<User>)

@Composable
fun UserList(users: UserList) {
    users.items.forEach { UserItem(it) }
}

@Immutable tells the Compose compiler: trust me, this type’s public properties won’t change after construction. With that contract in place, the composable becomes skippable.

For classes you own and actively mutate, use @Stable instead — it promises that equals() is consistent and any changes will notify observers via State.

@Stable
class UserViewModel : ViewModel() {
    var selectedId by mutableStateOf<String?>(null)
        private set
}

Keep your data classes under 50 lines. Large composables and bloated state holders are correlated — when a composable does too much, it inevitably captures more state than it needs, widening its recomposition scope.


3. Defer State Reads as Late as Possible

This is the highest-impact technique that most tutorials skip over.

When Compose reads a State object, it subscribes the current composable to that state. Read it too early — in the composition phase of a parent — and you pull the entire subtree into recomposition scope every time it changes.

The expensive pattern:

@Composable
fun AnimatedHeader(scrollState: ScrollState) {
    // Reading scrollState.value here means this entire composable
    // recomposes on every scroll pixel
    val alpha = scrollState.value / 255f
    Box(modifier = Modifier.alpha(alpha)) {
        HeaderContent()
    }
}

The deferred pattern — read inside a lambda:

@Composable
fun AnimatedHeader(scrollState: ScrollState) {
    Box(
        modifier = Modifier.graphicsLayer {
            // This lambda executes during the drawing phase, not composition
            // HeaderContent() never recomposes during scroll
            alpha = scrollState.value / 255f
        }
    ) {
        HeaderContent()
    }
}

By moving the state read into graphicsLayer { }, you push it from the composition phase into the drawing phase. HeaderContent never recomposes. Only the graphics layer updates — a fraction of the cost.

Apply the same pattern to any animation tied to scroll, touch, or sensor input.

Android developer optimizing code in Android Studio


4. remember and derivedStateOf — Use Them Correctly

remember caches a value across recompositions. But it only recomputes when its key changes — and if you get the key wrong, you either recompute too often or cache stale values.

Common mistake — forgetting the key:

@Composable
fun SearchResults(query: String, items: List<Item>) {
    // This recomputes on every recomposition, not just when query changes
    val filtered = remember { items.filter { it.name.contains(query) } }
}

Correct — key on the actual dependencies:

@Composable
fun SearchResults(query: String, items: List<Item>) {
    val filtered = remember(query, items) {
        items.filter { it.name.contains(query) }
    }
}

For derived state that depends on other State objects, reach for derivedStateOf. It creates a new State that only notifies its readers when its computed value actually changes — not every time its inputs change.

@Composable
fun ScrollToTopButton(listState: LazyListState) {
    // Without derivedStateOf: recomposes on every scroll event
    // With derivedStateOf: recomposes only when visibility crosses the threshold
    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }

    AnimatedVisibility(visible = showButton) {
        FloatingActionButton(onClick = { /* scroll to top */ }) {
            Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
        }
    }
}

Without derivedStateOf here, every scroll pixel triggers a recomposition of ScrollToTopButton. With it, recomposition only fires when firstVisibleItemIndex crosses zero — two state transitions instead of hundreds.


5. LazyColumn Performance: Keys and Content Types

LazyColumn is where Compose performance problems become visible fastest. A list with 500 items and no optimization configuration will stutter on any mid-range device.

Two settings that most developers ignore:

Keys tell Compose which item is which across recompositions. Without them, Compose assumes the list is stable by position — insert an item at the top and every item below it gets recomposed.

LazyColumn {
    items(
        items = users,
        key = { user -> user.id } // Stable, unique identity
    ) { user ->
        UserRow(user = user)
    }
}

Content types allow Compose to reuse composition nodes across different item types. If your list has headers and rows, tell Compose:

LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { item ->
            when (item) {
                is FeedItem.Header -> "header"
                is FeedItem.Post -> "post"
                is FeedItem.Ad -> "ad"
            }
        }
    ) { item ->
        when (item) {
            is FeedItem.Header -> HeaderRow(item)
            is FeedItem.Post -> PostCard(item)
            is FeedItem.Ad -> AdBanner(item)
        }
    }
}

Without contentType, Compose might try to reuse a header composition node for a post item — and fail, triggering a full recomposition. With it, only same-type nodes are reused, cutting composition work significantly.


6. Baseline Profiles: The Meta/Threads Playbook

TikTok reduced page load time by 78% and cut code size by 58% after rearchitecting their Android app with Compose. Meta’s Threads engineering team reported 40% performance improvements by combining Compose stability fixes with Baseline Profiles.

Baseline Profiles tell the Android Runtime which classes and methods to compile ahead of time instead of JIT-compiling on first use. The result: dramatically faster cold start and first-frame rendering.

Setting one up takes under 30 minutes:

// macrobenchmark/src/main/java/com/yourapp/BaselineProfileGenerator.kt
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(
        packageName = "com.yourapp"
    ) {
        pressHome()
        startActivityAndWait()

        // Walk the critical user journeys
        device.findObject(By.text("Feed")).click()
        device.waitForIdle()
    }
}

Run it once, commit the generated baseline-prof.txt to your repo, and every user gets pre-compiled critical paths on install. Cold start improvements of 20-40% are typical for Compose-heavy apps.

Mobile performance benchmark graphs showing before and after optimization


7. Avoid Backwards Writes

This one will crash your app in debug mode — and silently corrupt state in release.

A backwards write happens when a composable writes to a State object that was read earlier in the same composition pass. Compose detects this, restarts composition, and can loop indefinitely.

// WRONG — backwards write
@Composable
fun BadCounter() {
    var count by remember { mutableStateOf(0) }
    Text("Count: $count")
    count++ // Writing state that was already read above — triggers infinite recomposition
}

The fix is to move side effects into the appropriate effect handler:

// RIGHT — use LaunchedEffect or SideEffect for post-composition writes
@Composable
fun GoodCounter(onCountChanged: (Int) -> Unit) {
    var count by remember { mutableStateOf(0) }
    Text("Count: $count")
    Button(onClick = { count++ }) {
        Text("Increment")
    }
    SideEffect {
        onCountChanged(count)
    }
}

The rule: never write to state you’ve already read in the current composition pass. State writes belong in event handlers (onClick, onValueChange), LaunchedEffect, or SideEffect.


8. Profile First, Optimize Second

Every tip above is actionable — but don’t apply them blindly. Profile first.

Use Android Studio’s Compose Preview with the recomposition highlighter, then move to the Perfetto-based system trace for production-level analysis. Look for:

  • Composables highlighted in red (recomposing > 10x per second)
  • Long frames in the trace (>16ms)
  • Repeated composition of leaf nodes that shouldn’t change

The 60% of top Play Store apps now using Compose got there because Compose genuinely ships faster — 3x development speed, 45% less code than XML equivalents. But that productivity advantage disappears fast if you ship a janky app.

If you’re building or growing an Android app and want visibility into how users experience your performance improvements, AppBooster tracks ratings and review trends alongside release cycles — useful for correlating your optimization work with real user sentiment.

Android app analytics dashboard showing user rating trends


Recap: The Optimization Checklist

Apply these in order — the earlier items have the highest leverage:

  1. Enable recomposition count overlay in Layout Inspector. Know your baseline.
  2. Annotate data classes with @Stable or @Immutable where appropriate.
  3. Wrap List/Set/Map in stable holder classes before passing to composables.
  4. Defer state reads into graphicsLayer, drawBehind, or modifier lambdas.
  5. Key your remember calls on actual dependencies.
  6. Use derivedStateOf for any boolean/derived value computed from frequently-changing state.
  7. Add key and contentType to every LazyColumn/LazyRow.
  8. Generate a Baseline Profile and commit it to source control.
  9. Audit for backwards writes — look for state writes outside event handlers.
  10. Keep composables under 50 lines. If it’s longer, it’s doing too much.

Compose performance isn’t about clever tricks. It’s about understanding the recomposition model deeply enough that you stop working against it. Master that, and the framework’s smart diffing will do the heavy lifting for you.


Building an Android app and want to track how performance improvements affect your Play Store ratings? AppBooster gives you the review monitoring and analytics to close that feedback loop.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles