majorsenior2022

Stitch Fix Android

The Compose Recomposition Trap

The team adopted Jetpack Compose to move faster. Instead, the UI recomposed on every state change — even when nothing visible had changed.

The Incident

Stitch Fix's Android team adopted Jetpack Compose in 2022 and immediately hit production UI jank. Their feed — a list of styled clothing items with prices, ratings, and interactive actions — was recomposing far more than expected. The Recomposition Highlighter lit up like a Christmas tree. Three distinct bugs, each teaching a different lesson about how Compose's stability system actually works.

Evidence from the Scene

  • Recomposition Highlighter showed list items recomposing on every user action — even unrelated ones
  • A ViewModel instance was captured inside a lambda passed to list items
  • Domain model classes (Product, Price, Rating) lived in a non-Compose Gradle module
  • Scroll position was read directly in a composable that also displayed a 'scroll to top' button
  • The Layout Inspector showed recomposition counts in the hundreds after 10 seconds of use
  • Moving a data class from :domain to :ui module mysteriously fixed recomposition on that screen

The Suspects

3 of these are the real root causes. The others are plausible-sounding distractors.

Lambda capturing a ViewModel instance, making it unstable and non-skippable

Domain model classes from a non-Compose module inferred as unstable by the Compose compiler

Scroll position read directly in a composable, causing recomposition on every scroll pixel

Missing key parameter in LazyColumn items causing full list recomposition on any change

Using MutableList instead of ImmutableList as the type for list composables

The Verdict

Real Root Causes

  • Lambda capturing a ViewModel instance, making it unstable and non-skippable

    ViewModel is not a stable type in Compose's eyes. A lambda that captures a ViewModel reference is itself unstable — Compose cannot guarantee it hasn't changed, so composables receiving it never skip recomposition. The fix: use method references or remember { } to stabilize the lambda.

  • Domain model classes from a non-Compose module inferred as unstable by the Compose compiler

    Compose's stability inference only applies within modules that have the Compose compiler plugin enabled. Domain model classes in a pure Kotlin :domain module are always inferred as unstable — the compiler has no proof they haven't changed. The fix: map to @Stable UI model classes at the Compose boundary.

  • Scroll position read directly in a composable, causing recomposition on every scroll pixel

    Reading LazyListState.firstVisibleItemIndex directly in a composable that shows a 'scroll to top' button caused the entire composable to recompose on every scroll pixel. derivedStateOf { listState.firstVisibleItemIndex > 0 } recomposes only when the boolean result changes.

Plausible But Wrong

  • Missing key parameter in LazyColumn items causing full list recomposition on any change

    Missing keys cause all items to recompose on list changes — a real issue, but not what the clues describe. The ViewModel capture and cross-module instability are the root causes here.

  • Using MutableList instead of ImmutableList as the type for list composables

    MutableList is unstable in Compose and would cause recomposition — but the clues specifically point to lambda capture and cross-module inference as the culprits here, not the collection type.

Summary

Stitch Fix hit three distinct Compose stability bugs in one codebase: unstable lambda capture, cross-module stability inference failure, and unguarded scroll state reads. Each represents a different facet of Compose's compile-time stability system. The fixes — remember { }-wrapped lambdas, @Stable boundary mapping, and derivedStateOf — are now standard Compose performance practice. Published on the Stitch Fix engineering blog in August 2022, it remains one of the most cited real-world Compose performance post-mortems.

The Real Decision That Caused This

Adopting Compose without understanding that stability is a compile-time contract, that module boundaries break stability inference, and that every state read in a composable creates a recomposition subscriber.

Lesson Hint

Chapter 3 (Jetpack Compose) covers stability, @Stable, @Immutable, derivedStateOf, and the Recomposition Highlighter. Chapter 7 (Platform & Performance) covers profiling tools including Layout Inspector.

Want to test yourself before reading the verdict?

Open Interactive Case in Autopsy Lab