Compose in Production - What Google docs won't tell you
After migrating our banking app to Compose, we saw a 43% increase in ANRs and frame drops on low-end devices. Here’s what the documentation doesn’t tell you about Compose in production.
The Performance Killer Nobody Talks About
LazyColumn with dynamic item heights will destroy your performance.
We had a transaction list. Simple, right?
// This killed performance on Samsung A12 (our #2 device)
LazyColumn {
items(transactions) { transaction ->
TransactionCard(transaction) // Heights vary based on content
}
}
Frame time on scroll: 89ms (target is 16ms)
The fix that actually worked:
LazyColumn {
items(
items = transactions,
key = { it.id },
contentType = { it.type } // THIS IS CRITICAL
) { transaction ->
TransactionCard(
transaction = transaction,
modifier = Modifier.height(80.dp) // Fixed height
)
}
}
Frame time: 14ms
Why? Compose can’t pre-calculate scroll positions with dynamic heights. It recalculates on every frame.
The State Management Trap That Cost Us $50K
We had this innocent-looking code:
@Composable
fun AccountScreen() {
val accounts by viewModel.accounts.collectAsState()
LazyColumn {
items(accounts) { account ->
var isExpanded by remember { mutableStateOf(false) }
AccountCard(
account = account,
isExpanded = isExpanded,
onToggle = { isExpanded = !isExpanded }
)
}
}
}
The bug: When users scrolled, expanded cards would collapse randomly.
The business impact: Users couldn’t see their transaction details, called support. 3,400 calls × $15/call = $51,000.
The fix:
@Composable
fun AccountScreen() {
val accounts by viewModel.accounts.collectAsState()
// State hoisted outside items
val expandedIds = remember { mutableStateListOf<String>() }
LazyColumn {
items(accounts, key = { it.id }) { account ->
AccountCard(
account = account,
isExpanded = account.id in expandedIds,
onToggle = {
if (account.id in expandedIds) {
expandedIds.remove(account.id)
} else {
expandedIds.add(account.id)
}
}
)
}
}
}
Lesson: LazyColumn recycles composables. State inside items = state loss.
The Recomposition Hell That Melted Phones
Our home screen was recomposing 400+ times per second:
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val balance by viewModel.balance.collectAsState()
val transactions by viewModel.transactions.collectAsState()
Column {
// This recomposes when ANYTHING changes
BalanceCard(
balance = balance,
onRefresh = { viewModel.refresh() } // NEW LAMBDA EVERY TIME!
)
TransactionList(transactions)
}
}
The fix that dropped recompositions by 97%:
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val balance by viewModel.balance.collectAsState()
Column {
BalanceCard(balance = balance, viewModel = viewModel)
TransactionListContainer(viewModel = viewModel)
}
}
@Composable
fun BalanceCard(
balance: Balance,
viewModel: HomeViewModel
) {
// Read-only balance, stable reference to viewModel
val refresh = remember(viewModel) {
{ viewModel.refresh() }
}
Card(onClick = refresh) {
Text("$${balance.amount}")
}
}
@Composable
fun TransactionListContainer(viewModel: HomeViewModel) {
// Isolated recomposition scope
val transactions by viewModel.transactions.collectAsState()
TransactionList(transactions)
}
Result: Battery complaints dropped 78%.
The Animation That Crashed Samsung Phones
Never do this:
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000) // Runs forever
)
)
Box(
modifier = Modifier
.graphicsLayer { this.alpha = alpha } // Recomposes entire tree
.fillMaxSize()
)
This crashed 14% of Samsung A-series phones (memory leak + thermal throttling).
Do this instead:
Box(
modifier = Modifier
.fillMaxSize()
.drawBehind {
// Animation in draw phase, not composition
val alpha = ((System.currentTimeMillis() / 1000L) % 2).toFloat()
drawRect(Color.Black.copy(alpha = alpha))
}
)
The TextField That Lost User Input
This cost us a 1-star review bombing:
@Composable
fun TransferScreen() {
var amount by remember { mutableStateOf("") }
TextField(
value = amount,
onValueChange = { newValue ->
// Format as user types
amount = newValue.filter { it.isDigit() }
.take(10)
.formatAsCurrency() // PROBLEM!
}
)
}
fun String.formatAsCurrency(): String {
return if (isEmpty()) ""
else "$${toLong() / 100.0}" // Cursor jumps to end
}
Users couldn’t edit the middle of amounts. They’d rage quit.
The fix:
@Composable
fun TransferScreen() {
var amount by remember { mutableStateOf(TextFieldValue("")) }
TextField(
value = amount,
onValueChange = { newValue ->
val digits = newValue.text.filter { it.isDigit() }
if (digits.length <= 10) {
// Preserve cursor position
val newCursor = when {
newValue.selection.start > digits.length -> digits.length
else -> newValue.selection.start
}
amount = TextFieldValue(
text = digits,
selection = TextRange(newCursor)
)
}
},
visualTransformation = CurrencyVisualTransformation() // Format visually only
)
}
The Image Loading Disaster
Coil + Compose + RecyclerView migration = nightmare:
// This causes massive jank
@Composable
fun UserAvatar(url: String) {
AsyncImage(
model = url, // Re-fetches on every recomposition!
contentDescription = null
)
}
What actually works:
@Composable
fun UserAvatar(url: String) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.memoryCacheKey(url) // Stable cache key
.diskCachePolicy(CachePolicy.ENABLED)
.crossfade(false) // Crossfade causes recomposition
.size(Size.ORIGINAL) // Pre-sized, no layout shift
.build(),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
)
}
Reduced image-related jank by 91%.
The Navigation Memory Leak
This leaked 3MB per navigation:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") {
HomeScreen(
onNavigate = { route ->
navController.navigate(route) {
popUpTo("home") // Doesn't actually clear memory
}
}
)
}
// 20 more screens...
}
}
Fixed version:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
// Limit backstack
navController.addOnDestinationChangedListener { controller, _, _ ->
if (controller.backQueue.size > 10) {
controller.popBackStack(
controller.backQueue[1].destination.route!!,
inclusive = true
)
}
}
NavHost(navController, startDestination = "home") {
composable("home") {
HomeScreen(navController) // Pass controller, not lambdas
}
}
}
Memory usage: 142MB → 78MB
Production Metrics That Matter
After fixing these issues:
Performance:
- P95 frame time: 47ms → 12ms
- ANR rate: 0.31% → 0.04%
- Cold start: 2.1s → 0.9s
Business:
- Session length: +23%
- Transaction completion: +18%
- 1-star reviews mentioning “slow”: -84%
The Testing Strategy That Actually Catches Issues
Forget unit testing every Composable. Test what breaks in production:
@Test
fun `transaction list handles 10k items without dropping frames`() {
val transactions = List(10_000) { createTransaction(it) }
composeRule.setContent {
TransactionList(transactions)
}
composeRule.waitForIdle()
// Measure actual frame time
composeRule.mainClock.advanceTimeBy(16)
val frameTime = composeRule.mainClock.currentTime
assertThat(frameTime).isLessThan(16) // Must render in one frame
}
Tools That Actually Help
- Layout Inspector - Shows recomposition counts in real-time
- Perfetto - Actual frame timing, not theoretical
- Firebase Performance - Real device metrics
- Your mom’s 2019 Samsung - Best test device
Skip:
- Benchmark tests (unless you have 6 months to spare)
- Compose compiler metrics (noise > signal)
The One Rule for Compose Performance
If it’s slow on a Samsung A12, it’s broken.
30% of our users have budget phones. Optimize for them, not your Pixel 8 Pro.
Reality check: Compose is powerful but it’s not magic. It’s easier to write slow Compose code than slow XML. Test on real devices, measure real metrics, and remember that your users don’t care about your “reactive paradigm” when the app janks.