Compose in Production - What Google docs won't tell you

· Khoi Van

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

  1. Layout Inspector - Shows recomposition counts in real-time
  2. Perfetto - Actual frame timing, not theoretical
  3. Firebase Performance - Real device metrics
  4. 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.

Comments