Jetpack Compose is elegant. It's also a landmine.

The same reactive model that makes Compose declarative can silently swallow your coroutines, fire effects multiple times, and leave users staring at frozen spinners. I've seen these bugs ship to production—in my own apps and in code reviews across teams.

At Droidcon India 2025, I presented a talk on these patterns. This post goes deeper: the actual bugs, why Compose behaves this way internally, and how we fixed them in production at Equal AI.

The OTP That Never Verified: LaunchedEffect Self-Cancellation

This bug cost us a week of debugging. Users reported that SMS auto-fill "didn't work"—the OTP would populate, but verification never happened. The spinner just spun forever.

Here's the code that shipped:

var wasAutoFilled by remember { mutableStateOf(false) }

LaunchedEffect(wasAutoFilled) {
    if (!wasAutoFilled) return@LaunchedEffect

    wasAutoFilled = false   // Reset for next time
    delay(300)              // Small delay to feel natural
    onVerifyOTP(otpCode)    // Verify the OTP
    isVerifying = false     // Hide spinner
}

What we expected:

SMS fills OTP Effect triggers OTP verified Done ✓

What actually happened:

SMS fills OTP Effect triggers Flag resets CANCELLED ✗

Why This Happens: Compose's Recomposition Timing

Here's the timeline:

// 0ms
wasAutoFilled = true ← SMS arrives, LaunchedEffect starts
// ~1ms
wasAutoFilled = false ← KEY CHANGES, recomposition scheduled
// ~1ms
delay(300) ← coroutine suspends here...
// ~16ms — FRAME BOUNDARY
Recomposition runs → key changed → effect recreates
Old coroutine: CancellationException
onVerifyOTP() NEVER RUNS

The bug is subtle: changing the LaunchedEffect key schedules cancellation, but cancellation executes at the next suspension point. If you have no suspension, the code runs to completion before the frame boundary. Add a delay(), and you're dead.

The Fix: snapshotFlow Decouples Observation from Lifecycle

LaunchedEffect(Unit) {  // Key is Unit — NEVER changes
    snapshotFlow { wasAutoFilled }
        .filter { it }
        .collect {
            wasAutoFilled = false   // Safe! Just emits to the flow
            delay(300)              // Completes normally
            onVerifyOTP(otpCode)    // Actually executes
            isVerifying = false
        }
}

Why this works:

  • LaunchedEffect(Unit) starts once and never restarts
  • snapshotFlow observes state changes as Flow emissions
  • Changing wasAutoFilled emits a new value—it doesn't cancel the collector

The Shopping Cart That Lost Items: Mutable Collection Mutation

A user reported: "I added 5 items to my cart, but only 2 showed up." We checked the database—all 5 were there. The bug was in the UI.

var cartItems by remember {
    mutableStateOf(mutableListOf<CartItem>())
}

Button(onClick = {
    cartItems.add(newItem)  // Items added internally...
    // ...but UI never updates!
})

Why This Happens: Reference Equality

Compose uses reference equality to detect state changes. When you call cartItems.add(), you're mutating the same list object:

❌ Mutation
List@a1b2c3 → [2 items]
List@a1b2c3 → [3 items]
Same reference = no recomposition
✓ New list
List@a1b2c3 → [2 items]
List@d4e5f6 → [3 items]
New reference = recomposition ✓

The Fix: Immutable Updates or mutableStateListOf

// Option 1: Create new list
var cartItems by remember { mutableStateOf(listOf<CartItem>()) }
cartItems = cartItems + newItem  // New reference

// Option 2: Use Compose's observable list
val cartItems = remember { mutableStateListOf<CartItem>() }
cartItems.add(newItem)  // Automatically triggers recomposition

The Duplicate Snackbar: Events Are Not State

Error handling seemed simple:

// In ViewModel
var errorMessage by mutableStateOf<String?>(null)

// In Composable
LaunchedEffect(viewModel.errorMessage) {
    viewModel.errorMessage?.let { error ->
        snackbarHostState.showSnackbar(error)
    }
}

Bug: Rotate the device while the snackbar is showing. It shows again. And again on every configuration change.

Why This Happens

Configuration Change Timeline:
1. Activity recreates
2. ViewModel survives → errorMessage = "Save failed"
3. LaunchedEffect observes non-null
4. Snackbar shows AGAIN

The Fix: Use Channels for One-Time Events

// In ViewModel
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

// In Composable
LaunchedEffect(Unit) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
        }
    }
}

The Janky Scroll: State Read Too High

Performance profiling showed our list was recomposing on every frame during scroll. 60 recompositions per second.

@Composable
fun ProductListScreen() {
    val scrollState = rememberLazyListState()
    val showScrollToTop = scrollState.firstVisibleItemIndex > 5  // ← Read here!

    Column {
        TopBar(showScrollToTop)   // Recomposes on scroll
        ProductList(scrollState)  // Recomposes on scroll
        BottomNav()               // Recomposes on scroll (!)
    }
}

Why This Happens: Recomposition Scope

ProductListScreen (reads scrollState)
├── TopBar recomposes
├── ProductList recomposes
└── BottomNav recomposes
Every scroll = entire tree recomposes

The Fix: Push State Reads Down

@Composable
fun ScrollAwareTopBar(scrollState: LazyListState) {
    val showScrollToTop by remember {
        derivedStateOf { scrollState.firstVisibleItemIndex > 5 }
    }
    // Only THIS composable recomposes on scroll
    TopBar(showScrollToTop)
}

State Machines: Making Impossible States Impossible

All these bugs share a root cause: invalid state combinations that shouldn't exist.

// 4 booleans = 16 combinations. Valid states? Maybe 5.
data class CheckoutState(
    val isLoading: Boolean,
    val isError: Boolean,
    val isSuccess: Boolean,
    val isProcessingPayment: Boolean
)

The Fix: Sealed Interfaces

sealed interface CheckoutState {
    data object Idle : CheckoutState
    data object Loading : CheckoutState
    data class ProcessingPayment(val order: Order) : CheckoutState
    data class Success(val receipt: Receipt) : CheckoutState
    data class Error(val message: String) : CheckoutState
}

Now only valid states can exist. The compiler enforces exhaustive handling.


Production Results

After implementing these patterns at Equal AI:

Metric Before After
Crash rate 0.4% 0.1%
ANR rate 0.2% 0.05%
"UI stuck" reports 23/week 3/week
Test coverage (state) 34% 89%

Resources

Slides: View on SpeakerDeck

Code: compose-patterns-playground

The playground includes interactive broken/fixed demos for all 12 anti-patterns.

Key Takeaways

  1. LaunchedEffect keys control lifecycle — Changing the key cancels the coroutine
  2. Compose uses reference equality — Mutating collections doesn't trigger recomposition
  3. Events are not state — Use Channel for one-time events
  4. State reads define recomposition scope — Read state as low as possible
  5. Sealed interfaces prevent impossible states — Boolean combinations explode

Compose isn't slow. Misusing Compose is slow. Learn the patterns, avoid the traps, ship fewer bugs.

Presented at Droidcon India 2025