Compose Performance & Stability
Stability, skipping, deferred reads, phases, Lazy keys, Modifier.Node, compiler metrics
- 01
A screen drops frames while a value animates, but the Layout Inspector shows almost no recompositions — which phase is burning the time?
MediumCompose runs composition, layout and drawing as three separate phases, and zero recompositions means the cost has moved into the other two — so attribute the frame to a phase before you change any code.
// Cheap phase attribution with no profiler: each phase logs separately. fun Modifier.phaseProbe(tag: String) = this .layout { measurable, constraints -> Log.d("phase", "$tag measure") val placeable = measurable.measure(constraints) layout(placeable.width, placeable.height) { … - 02
The compiler report marks your
ArticleCardunstable because it takes aList<Tag>— what does Compose's stability inference actually check?MediumStability is a promise about comparison: a type is stable when
equalsis a trustworthy answer to "did this change", and when any change to a public property is published to the composition through snapshot state.// From <module>-classes.txt in the Compose compiler report: // // unstable class Article { // stable val id: String // unstable val tags: List<Tag> <- this field poisons the whole class // } … - 03
Strong skipping has been the default since the Compose compiler shipped with Kotlin 2.0.20 — what did it change, and what does it still not fix?
MediumStrong skipping makes every restartable composable skippable by comparing unstable parameters with instance identity instead of refusing to skip at all, and it memoizes lambdas for you.
data class Article(val id: String, val tags: List<Tag>) // unstable param @Composable fun Feed(articles: List<Article>) { LazyColumn { items(articles, key = { it.id }) { article -> … - 04
A header slides with the scroll via
Modifier.offset(y = scroll.value.dp)and the whole screen recomposes every frame — what doesModifier.offset { }change?MediumThe lambda overload moves the state read out of composition and into the layout phase, so a value changing every frame re-runs placement instead of re-running your composable functions.
@Composable fun CollapsingHeader(listState: LazyListState) { // ❌ Composition read: the body of CollapsingHeader — and everything it // emits — re-runs on every scroll frame. Box( Modifier … - 05
A reviewer says your
remember { derivedStateOf { items.filter(query::matches) } }should just beremember(items, query)— who is right?MediumThe reviewer is right:
derivedStateOfonly tracks snapshot state reads, so over ordinary parameters it captures the first values in its closure and never recomputes.// ❌ Stale forever: `items` and `query` are parameters, not snapshot state, // so the closure keeps whatever was passed on the first composition. @Composable fun BadResults(items: List<Item>, query: String) { val filtered by remember { derivedStateOf { items.filter { it.matches(query) } } } ResultList(filtered) … - 06
A feed with three different row layouts still drops frames on fast scroll even though every item has a stable key — what else does a
LazyColumnneed?Mediumkeycontrols identity;contentTypecontrols reuse — without it every row is a candidate for every recycled slot, so a slot that held a text row gets handed to a photo row and the runtime throws the whole subtree away instead of updating it.sealed interface Row { data class Header(val id: String, val title: String) : Row data class Photo(val id: String, val url: String) : Row data class Comment(val id: String, val text: String) : Row } … - 07
Your feed screen recomposes far more than it should — how do you get the Compose compiler itself to tell you which parameter is to blame?
MediumSwitch on the compiler's reports and metrics in the
composeCompilerblock, build the variant you ship, and read<module>-composables.txt, which prints every composable with arestartable/skippableheader and each parameter markedstableorunstable.// build.gradle.kts composeCompiler { // ./gradlew assembleRelease -PcomposeReports if (project.findProperty("composeReports") != null) { reportsDestination = layout.buildDirectory.dir("compose_reports") metricsDestination = layout.buildDirectory.dir("compose_metrics") … - 08
The Layout Inspector shows 300 recompositions on a header that never changes — how do you read that number and trace it back to a state read?
MediumA recomposition count is how many times that restart scope actually re-ran, and 300 on a static header means something invalidated it 300 times — the Skips column beside it tells you whether the runtime managed to bail out or not.
// A count you can read without the inspector, in any build: // the array survives recomposition, SideEffect runs once per composition // that was actually applied. @Composable fun LogRecompositions(tag: String) { val count = remember { IntArray(1) } … - 09
A child stops skipping the moment its parent passes a lambda that reads an animating progress value — why, and how do you fix it without the callback going stale?
HardStrong skipping does memoize the lambda, but the memo is keyed on what the lambda captures — capture a value that changes every frame and you allocate a new lambda every frame, so the child's parameter comparison fails.
// A new lambda every frame: the compiler's memo is keyed on `progress` @Composable fun Player(progress: Float, onSeek: (Float) -> Unit) { Controls(onSkip = { onSeek(progress + 10f) }) // Controls never skips } … - 10
Rotating the phone swaps the layout from a
Columnto aRowand the video player restarts from zero — how do you move a subtree between parents without losing its state?HardState in Compose is identified by position in the composition, so calling the same composable from a different call site creates a different group: the old one is disposed and a new one built from scratch —
movableContentOfis the API that relocates a group instead.// Two call sites: rotating disposes the player and builds a new one @Composable fun ScreenBroken(wide: Boolean, url: String) { if (wide) Row { Player(url); Details() } else Column { Player(url); Details() } } … - 11
Your custom
Modifier.pulsingBorder()is written withcomposed { }and shows up in every recomposition profile — what does it cost, and what changes if you rewrite it as aModifier.Node?HardA
composed { }modifier is a composable that runs once per usage site on every composition, and because twocomposedinstances never compare equal it also defeats the equality check the modifier chain would otherwise use to skip work.// composed: a composition group per usage, no equals, invalidates composition fun Modifier.pulsingBorderSlow(color: Color) = composed { val alpha by rememberInfiniteTransition(label = "pulse") .animateFloat(0f, 1f, infiniteRepeatable(tween(800)), label = "alpha") drawBehind { drawRect(color.copy(alpha = alpha), style = Stroke(2f)) } } … - 12
Someone put a
BoxWithConstraintsinside everyLazyColumnitem and the scroll got worse — what does subcomposition actually cost, and what should they have used?HardBoxWithConstraintsis aSubcomposeLayout: it moves the composition of its children into the measure pass, so each row now composes during layout, every time it is measured, instead of once during composition.// A SubcomposeLayout per row: composition moves into the measure pass LazyColumn { items(posts, key = { it.id }) { post -> BoxWithConstraints { if (maxWidth > 600.dp) WideRow(post) else NarrowRow(post) } … - 13
The compiler report says your feed row is unskippable because of a model class from a third-party SDK — what are your options, and what does each one cost you?
HardThree real options — declare the class stable in a stability configuration file, map it into a type you own, or apply the Compose compiler to the module that produces it — and only the middle one is a promise you can actually keep.
// build.gradle.kts composeCompiler { stabilityConfigurationFiles.add( rootProject.layout.projectDirectory.file("compose_stability.conf") ) reportsDestination = layout.buildDirectory.dir("compose_reports") … - 14
A freshly installed build janks through the first scroll of a Compose list and is smooth on every scroll after — what is the runtime doing, and what actually fixes it?
HardEverything on that first pass is cold: ART is interpreting code that has never been compiled, classes are still being loaded, the slot table and layout tree are being built for the first time, and the glyph and shader caches are empty.
// Generate the profile for THIS journey, not just for cold start @RunWith(AndroidJUnit4::class) class FeedBaselineProfile { @get:Rule val rule = BaselineProfileRule() @Test fun feedScroll() = rule.collect(packageName = "com.example.app") { … - 15
How do you stop a Compose performance regression from merging, and what makes a scroll number stable enough to fail a pull request on?
HardUse two gates with different characters: a compiler-metrics diff that is fully deterministic and runs in seconds on every PR, and a Macrobenchmark scroll on one fixed device that only means anything once you control the noise.
// build.gradle.kts - deterministic, runs on every PR composeCompiler { metricsDestination = layout.buildDirectory.dir("compose_metrics") reportsDestination = layout.buildDirectory.dir("compose_reports") } … - 16
Recomposition counts look clean but frames are still late and the time is sitting on RenderThread — where does a Compose UI usually burn GPU time?
HardLook for offscreen buffers and fill rate: an alpha or a blur that forces the renderer to allocate a layer, shadows and non-rectangular clips, and stacked opaque backgrounds painting the same pixels several times over.
// An offscreen buffer for a fade that did not need one Box(Modifier.alpha(fade)) { Row { Icon(icon, null); Text(label) } } // Children do not overlap -> modulate alpha per draw op, no offscreen buffer Box( Modifier.graphicsLayer { …