Every Favorite Tap Was Redrawing Every Visible Wallpaper — Stopping Needless Recomposition in Compose, Measured
A record of fixing a wallpaper grid where toggling a single favorite recomposed every visible thumbnail. Covers turning on Composition Tracing to measure first, tracking the unstable parameter down, stabilizing the data model, handling lambdas and derived state, and comparing recomposition counts before and after from an indie developer's view.
While reworking the favorites screen of one of my wallpaper apps, I noticed that every time I tapped the heart on a thumbnail, the dozen-or-so thumbnails on screen flickered for a frame. Only the tapped tile should change, yet every visible cell in the viewport looked like it was being redrawn.
During a scroll, that flicker turns into a stutter. On my own Pixel it stayed barely smooth, but on the older phone a friend lends me it clearly hitched. When you maintain the same app for years as an indie developer, you get used to the speed of your own device and stop noticing this kind of regression. I caught it while scrolling and mashing the favorite button on a slow device. The cause was that Jetpack Compose's recomposition scope had quietly grown too wide, so I started by turning "how many times is this redrawing?" into a number.
Don't start tuning on a guess — make recomposition counts visible
The worst thing you can do with a Compose performance problem is sprinkle remember and key around on intuition. If you change things without knowing which Composable recomposes how often, you can't tell whether it helped, and you break something else in the process.
The first step was enabling Composition Tracing. It makes the System Trace show, by name, which Composable functions composed and recomposed and how many times. The setup itself is boilerplate, so I asked the Antigravity agent to "enable composition tracing for debug builds only" and reviewed the diff it produced.
// app/build.gradle.ktsdependencies { // For Composition Tracing (debug is enough) implementation("androidx.tracing:tracing-perfetto:1.0.0") implementation("androidx.tracing:tracing-perfetto-binary:1.0.0")}// Tell the Compose compiler to emit composition trace markerscomposeCompiler { // Only true for debug measurement. Never ship this in release. includeTraceMarkers = true}
I captured the trace with the System Trace profiler in Android Studio, or by launching androidx.tracing.perfetto from Macrobenchmark. After launch I toggled one favorite, opened the trace in Perfetto, and counted the slices for the thumbnail Composable. That was the moment "one toggle drives recompositions equal to the visible cell count" stopped being a suspicion and became a fact.
The culprit was an unstable parameter
The classic reason recomposition spreads is that an argument passed to a Composable is judged "unstable" by the Compose compiler. Any Composable that takes even one unstable argument drops out of being skippable, and gets recomposed unconditionally whenever its parent recomposes.
My grid item was receiving this data class.
// Looks like an ordinary model, but Compose sees it as unstabledata class WallpaperItem( val id: String, val thumbnailUrl: String, val tags: List<String>, // List is an interface, treated as unstable var isFavorite: Boolean, // var is the clincher for instability)
List<String> is an interface type whose implementation could be swapped, so the compiler can't assume its contents won't change. And a var property is the clincher, because there's no guarantee about when it gets reassigned. Because this one model was unstable, the whole thumbnail Composable that received it stopped being skippable, and every time the favorites Set updated and the parent recomposed, all the visible cells were dragged along.
You can confirm the stability verdict in the Compose compiler's report output.
# With compiler reports configured in build.gradle.kts./gradlew assembleRelease \ -Pandroidx.compose.compiler.reports.destination=build/compose_reports# The generated *-classes.txt lists stable/unstable per class# e.g. unstable class WallpaperItem
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Wire up Composition Tracing so you can read how many times each Composable recomposes per interaction as a number, not a guess
✦Trace why a LazyVerticalGrid redrew every visible thumbnail on each favorite toggle down to the unstable parameter, then narrow recomposition to the single tapped cell with @Immutable, stable keys, and derivedStateOf
✦Compare recomposition counts and scroll frame times before and after, and find a line for how much measurement to delegate to an agent versus which design decisions to keep yourself
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Make the model stable — @Immutable and immutable collections
There are two ways to fix it. One is to make the model genuinely immutable and tell the compiler so. The other is to lean on strong skipping mode. I made the first my main approach. If I can guarantee the data is immutable myself, the behavior is easier to reason about.
import androidx.compose.runtime.Immutableimport kotlinx.collections.immutable.ImmutableListimport kotlinx.collections.immutable.toImmutableList@Immutabledata class WallpaperItem( val id: String, val thumbnailUrl: String, val tags: ImmutableList<String>, // immutable collection -> stable val isFavorite: Boolean, // var becomes val; hold state outside)// Build it once, immutablyfun List<Wallpaper>.toUiItems(favorites: Set<String>): ImmutableList<WallpaperItem> = map { w -> WallpaperItem( id = w.id, thumbnailUrl = w.thumbUrl, tags = w.tags.toImmutableList(), isFavorite = w.id in favorites, ) }.toImmutableList()
@Immutable is an annotation that promises the compiler "instances of this class have no observable changes after construction." If you break the promise (the contents change later), the screen stops updating, so only add it when the type really is immutable. Using ImmutableList from kotlinx.collections.immutable makes the collection type count as stable too.
Enabling strong skipping mode (Compose 2.0 and later) lets even Composables with unstable arguments skip "as long as the instance is the same reference," which softens this whole class of problem considerably. Still, I wanted the model's immutability to be explicit in the design, so I turned strong skipping on as a safety net while also stabilizing the data class itself.
Move favorite state outside the item
If you bake isFavorite into the item model, changing a single favorite means rebuilding the whole list, and every item ends up being "a different value" and recomposes anyway. I held the favorites set one level up and passed each cell only "am I a favorite?", carved out with derivedStateOf.
@Composablefun WallpaperGrid( items: ImmutableList<WallpaperItem>, favorites: SnapshotStateMap<String, Boolean>, onToggleFavorite: (String) -> Unit,) { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 108.dp), ) { items( items = items, key = { it.id }, // stable key; avoids wrong reuse on reorder/insert contentType = { "thumb" }, // helps same-type cell reuse ) { item -> // This derived state reads only "this item's favorite". // It does not recompose when other items' favorites change. val isFav by remember(item.id) { derivedStateOf { favorites[item.id] == true } } WallpaperCell( item = item, isFavorite = isFav, onToggleFavorite = onToggleFavorite, // pass a stable function reference ) } }}
Three things are doing the work here. key = { it.id } pins each cell's identity to its ID so a reorder or insert doesn't accidentally inherit another item's state. derivedStateOf subscribes only to "my own favorite" and ignores changes to other members of the favorites set. And onToggleFavorite flows down as a stable function reference instead of a freshly built lambda each time. If you rebuild the callback inside the cell as { onToggleFavorite(item.id) }, that itself tends to become a changing argument, so keep the ID captured on the cell side and call it there.
Measured — recomposition counts and frame times, before and after
Instead of calling it done once it felt fixed, I ran the very first step again. With roughly 14 cells visible, here are the recomposition counts for the thumbnail Composable on a single favorite toggle. I also captured frame times while scrolling and mashing the toggle (Macrobenchmark's FrameTimingMetric). These are values on my mid-range physical device; numbers vary by hardware, but the trend reproduces.
Metric
Before
After
Cell recompositions per toggle
~14 (all visible)
1 (only the tapped one)
Frame time on toggle, P50
~11.8 ms
~4.6 ms
Frame time, scroll + mashing, P90
~27 ms (a few dropped)
~9 ms (none dropped)
WallpaperItem stability (compiler report)
unstable
stable
The recomposition that had moved every visible cell now stayed on the single tapped one. With P90 frame time under the 16.7 ms budget for 60fps, mashing-while-scrolling on a slow device no longer drops frames. What mattered here was not stopping at "feels faster" but re-capturing the same numbers under the same conditions as before. Compose optimizations regress easily — forget to keep one stability annotation and you're back where you started — so it's only truly fixed when the before and after numbers line up.
One pitfall remains. If you later mutate the contents of a model you marked @Immutable somewhere, the compiler keeps trusting the skip and stops updating the screen. Moving favorite state out of the model wasn't only cosmetic; it was a design choice to avoid telling that lie.
How much to delegate to the agent, and what to keep
What I delegated to the agent was the Composition Tracing wiring, the Gradle config to emit the compiler report, and reading back "the list of classes reported as unstable." Building the measurement scaffold is boilerplate, and a machine is faster and more accurate at it.
The decision to pull isFavorite out of the model and lift state up a level, though, I kept for myself. It looks like a performance problem but is really a question of where data ownership belongs. The agent proposes the shortest path to "turn unstable into stable," but whether to change var to val and externalize the state, or to push through with strong skipping, should be decided by the person who knows the lifetime and update frequency of that app's data. Delegate the measurement, keep the design. That's my own line when pairing with an agent in solo development.
The next time you meet the same symptom, run composeCompiler.reports.destination once and look over the list of unstable classes. Most of the reasons recomposition spreads are written in the first few lines of that list.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.