
Android Jetpack Compose Debugging: Fix Common Crashes
Compose UI frameworks like Android Jetpack Compose promise faster UI development with less boilerplate, but they introduce a new class of runtime crashes that don't exist in traditional View-based Android apps. Recomposition loops, snapshot state violations, and Modifier ordering bugs can crash your app silently — and the standard Android crash reporting tools often miss the root cause. This guide walks through the six most common Jetpack Compose crash patterns and how to detect, fix, and monitor them in production.
Recomposition Loop Crashes — The Infinite Layout Cycle
The most infamous Compose crash pattern is the recomposition loop — a state-driven infinite cycle that freezes the UI thread and terminates the app with an OutOfMemoryError or ANR. This happens when a composable reads state during layout or drawing, triggering a recomposition that re-reads the same state in a cycle. The result is an exponentially growing recomposition count that saturates the main thread within seconds.
How to detect it: The first symptom is a frozen UI followed by an ANR dialog. In the ANR trace, look for the stack trace dominated by Composer.recompose() calls — you'll see the same composable names repeating hundreds of times. On devices running Android 12+, the system may terminate the app directly with an OutOfMemoryError if the recomposition count exceeds 10,000 iterations within a single frame window.
The fix: Never read or write state inside Modifier.layout() or Canvas.onDraw(). Use remember { mutableStateOf() } with snapshotFlow() to defer state updates. For animations, use Animatable or animateFloatAsState rather than manual LaunchedEffect + mutableStateOf loops. Always verify that state reads in drawWithContent or drawBehind are reading derived values, not raw mutable state.
// RECOMPOSITION BOMB — reads state inside draw
Modifier.drawWithContent {
if (someState.value) drawRect(Color.Red) // Triggers recompose!
drawContent()
}
// SAFE — use a derived state
val shouldShowRed by remember { derivedStateOf { someState.value } }
Modifier.drawWithContent {
if (shouldShowRed) drawRect(Color.Red)
drawContent()
}Research from Google's performance team shows that 34% of Compose ANRs in production are caused by measurable recomposition loops Compose performance patterns for measurable recomposition. To detect them in your own app, add a recomposition counter in debug builds: println("Recomposed: ${currentComposer.recomposeCount}"). In production, use a monitoring tool that captures recomposition counts as custom breadcrumbs — if you see any composable exceeding 50 recompositions per frame, it needs investigation.
Real-world example: A popular note-taking app experienced intermittent ANRs that only appeared when users typed quickly. The root cause was a TextField whose onValueChange lambda triggered a recomposition of the entire parent Column via an inline mutableStateOf write — each keystroke caused 300+ recompositions. The fix was to hoist the keyboard state to a ViewModel with StateFlow and collect it with collectAsStateWithLifecycle().
Snapshot State Violation: Concurrent Modification in Compose
Jetpack Compose uses a snapshot state system for thread-safe reads and writes. When you modify a mutableStateOf value from a background thread without proper synchronization, Compose throws a SnapshotStateException with the message "Snapshot state modification from unexpected thread". This is particularly common when updating UI from a coroutine launched on Dispatchers.IO.
The fix: Always use Dispatchers.Main when writing to Compose state. For coroutine-based updates, use viewModelScope.launch { stateFlow.collect { uiState = it } } inside a LaunchedEffect. For callback-based APIs (like listeners from third-party SDKs), wrap state updates in withContext(Dispatchers.Main).
// WRONG — crashes with SnapshotStateException
scope.launch(Dispatchers.IO) {
val result = api.fetchData()
uiState = result // Snapshot violation!
}
// RIGHT
scope.launch {
val result = withContext(Dispatchers.IO) { api.fetchData() }
uiState = result // Already on Main dispatcher
}A 2025 analysis of 200+ Compose apps found that snapshot violations accounted for 19% of all production Compose crashes Android Developers Blog: snapshot state debugging. For more context on crash reporting in production, see our guide on production crash monitoring for Android apps.
Modifier Order Crashes — Silent UI Breakage
The order of Modifier calls in Compose is NOT commutative — different orderings produce different layout behavior, and certain orderings produce invisible UI. A common crash vector is placing Modifier.clickable() before Modifier.padding() — the click ripple clips to the padded area and becomes invisible, which users perceive as a "dead button." While this doesn't crash the app technically, it causes a functional crash in user experience, leading to 1-star reviews.
The fix: Follow the standard order: Modifier.size(...).padding(...).clip(...).clickable(...). The .size() and .padding() modifiers come first, then layout tweaks like .clip(), then interaction modifiers like .clickable() last. When using Modifier.weight() in a Row or Column, it must come after any fixed-size modifiers.
// WRONG — invisible click area
Modifier.clickable { }.padding(16.dp).background(Color.Blue)
// RIGHT — full clickable area
Modifier.padding(16.dp).background(Color.Blue).clickable { }Other common modifier ordering mistakes:
Modifier.background()beforeModifier.padding()means the background fills the full available space, not the padded area — making the background extend beyond the visual content areaModifier.border()beforeModifier.clip()draws the border outside the clip boundary, causing sharp corners instead of rounded onesModifier.offset()beforeModifier.clickable()moves the visual without moving the touch target — the user taps where they see the element but nothing happens
A systematic audit of 500+ Compose screens found that modifier ordering bugs caused invisible touch targets in 18% of production apps Jetpack Compose Modifier documentation. The fix is simple: always apply size constraints first, then decoration (background, border), then clipping, then interaction modifiers.
LazyList Item Key Collisions
When using LazyColumn or LazyRow, every item MUST have a unique key. Duplicate keys cause IllegalStateException: "key XX was already used" — a hard crash during scrolling. This is especially common when items come from a list that contains duplicates (e.g., two error states with the same default key) or when using index as the key (which breaks with item animations or reordering).
The fix: Always provide explicit keys via the key parameter, using a stable unique identifier from your data model. Never use the list index as a key.
// CRASH RISK — duplicate keys
LazyColumn {
items(items) { item -> ItemRow(item) }
}
// SAFE
LazyColumn {
items(items, key = { it.id }) { item -> ItemRow(item) }
}A production analysis across 50 Compose apps revealed that 12% of LazyColumn crashes came from key collisions Bugspulse Community: LazyList crash patterns.
Text Measurement Before Composition Phase
Calling TextMeasurer.measure() or accessing BoxWithConstraints properties outside of the composition/layout phase causes IllegalStateException: "Measure is not allowed". This happens when developers try to pre-calculate text dimensions in a LaunchedEffect or callback before the composable enters the layout phase.
When does this happen in practice? Three common scenarios:
- Pre-composition measurement — Calling
TextMeasurer.measure()inside aLaunchedEffectthat fires before the composable's first layout pass - Animation listeners — Reading
BoxWithConstraintsminWidth/minHeight inside anAnimatableupdate callback that fires before layout - Conditional composition — Placing
BoxWithConstraintsinside a condition that depends on data loaded asynchronously — the composable may not have entered the layout phase when the data arrives
The fix: Use onSizeChanged or onGloballyPositioned modifiers to capture dimensions during layout. For text measurement, use drawText with a TextLayoutResult obtained from rememberTextMeasurer().
var textSize by remember { mutableIntStateOf(0) }
Text(
text = "Hello",
onTextLayout = { result -> textSize = result.size.height },
modifier = Modifier
)For BoxWithConstraints, use onGloballyPositioned to capture layout coordinates and dimensions after the first composition phase is complete:
var constraintsWidth by remember { mutableIntStateOf(0) }
Box(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
constraintsWidth = coordinates.size.width
}
)This is specified in the Jetpack Compose Text documentation. The onGloballyPositioned callback fires after the layout phase, so it's always safe to read dimensions inside it.
Runtime Permission Dialog Crashes in Compose
Requesting runtime permissions in Compose requires careful lifecycle management. The rememberLauncherForActivityResult contract in Compose must be called at the top level of a composable — not inside conditionals. When placed inside an if (condition) { } block, the launcher is never registered, and calling launch() throws IllegalStateException.
The fix: Always call rememberLauncherForActivityResult outside of any conditional block. Use a state variable to track whether the dialog should be shown.
// CRASH — launcher inside conditional
if (needsPermission) {
val launcher = rememberLauncherForActivityResult(...)
// IllegalStateException when used
}
// SAFE
val launcher = rememberLauncherForActivityResult(...)
if (needsPermission) {
LaunchedEffect(Unit) { launcher.launch(permission) }
}The Android documentation covers this under the Activity result API guide.
Building a Monitoring Strategy
These six crash patterns share a common thread: they're nearly impossible to catch in unit tests because they depend on runtime composition ordering, snapshot state threading, and real device interaction patterns. That's where production monitoring is essential.
Recommended monitoring setup:
- Recomposition counters — Add a global recomposition threshold monitor in debug builds
- Snapshot violation logging — Catch
SnapshotStateExceptionin a global exception handler - ANR traces — Enable
Thread.getUncaughtExceptionPreHandlerto capture ANR traces with recomposition stack info - LazyList key audits — Run a pre-release check script that validates all
LazyColumn/LazyRowkey uniqueness - User session replays — Use a tool that captures UI state before each crash to understand what composition was in progress
Bugspulse supports all of these patterns with its Compose-first SDK — tracking snapshot violations, recomposition loops, and ANRs with full breadcrumb context. You can see which composable was rendering when the crash occurred and what state mutations led up to it.
Start monitoring your Compose app for free at Bugspulse — no credit card required. For Compose-specific crash grouping and snapshot state tracking, sign up at app.bugspulse.com/register.