Measuring and Reducing Jank in Compose Apps
A systematic approach to identifying, measuring, and eliminating frame drops in Jetpack Compose applications, with concrete patterns and tooling strategies.
Context
Jank is the visible stutter when an app drops frames. On a 60Hz display, each frame has a 16.67ms budget. On 120Hz, that budget shrinks to 8.33ms. Jetpack Compose splits frame work into three phases: composition, layout, and drawing. Any phase exceeding its budget causes a dropped frame.
Problem
Compose apps can jank for reasons fundamentally different from View-based apps. Recomposition replaces invalidation. The slot table replaces the view tree. Layout in Compose uses intrinsic measurements and constraints differently. Teams migrating from Views often apply View-era optimization intuitions that do not transfer, or worse, miss Compose-specific performance pitfalls entirely.
Constraints
- A single frame must complete composition, layout, and draw within the frame deadline
- Compose compiler decides recomposition scopes based on function boundaries and stability
LazyColumnrecycles compositions but does not recycle views likeRecyclerView- State reads during composition trigger recomposition. State reads during layout trigger relayout only
- Background work that blocks the main thread delays frame delivery regardless of Compose optimizations
Design
The Three Phases
| Phase | What Happens | Jank Cause |
|---|---|---|
| Composition | Compose runtime executes composable functions, diffs the slot table | Too many composables recomposing, expensive computations in composables |
| Layout | Measures and places each node in the layout tree | Deeply nested layouts, intrinsic measurements, repeated measure passes |
| Drawing | Renders the layout tree to the canvas | Complex drawing operations, large bitmaps, shader compilation |
Measuring Jank
JankStats API
The androidx.metrics library provides JankStats, which reports per-frame timing with user-defined state annotations.
class MainActivity : ComponentActivity() {
private lateinit var jankStats: JankStats
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
val durationMs = frameData.frameDurationUiNanos / 1_000_000.0
Log.w("Jank", "Dropped frame: ${durationMs}ms, states: ${frameData.states}")
analytics.trackJank(durationMs, frameData.states)
}
}
}
override fun onResume() {
super.onResume()
jankStats.isTrackingEnabled = true
}
override fun onPause() {
super.onPause()
jankStats.isTrackingEnabled = false
}
}Add state annotations to correlate jank with specific screens:
@Composable
fun FeedScreen(jankStats: JankStats) {
DisposableEffect(Unit) {
val state = PerformanceMetricsState.getHolderForHierarchy(localView).state
state?.putState("screen", "feed")
onDispose {
state?.removeState("screen")
}
}
// ...
}Perfetto Traces
Compose emits trace sections for each phase. Capture with:
# Record a Perfetto trace with Compose-specific markers
adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace -t 10s \
-c - <<EOF
buffers: {
size_kb: 65536
}
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "ftrace/print"
atrace_categories: "view"
atrace_apps: "com.example.app"
}
}
}
EOFIn the trace, look for Compose:recompose, Compose:layout, and Compose:draw slices. Slices exceeding the frame deadline are your targets.
See also: Event Tracking System Design for Android Applications.
Reducing Composition-Phase Jank
Problem: Unstable parameters causing unnecessary recomposition
// Jank: data class with List parameter is unstable
data class FeedState(
val posts: List<Post>, // List is unstable
val isLoading: Boolean
)
@Composable
fun FeedScreen(state: FeedState) {
// Recomposes on every state emission, even if posts haven't changed
LazyColumn {
items(state.posts, key = { it.id }) { post ->
PostCard(post) // Every PostCard recomposes too
}
}
}
// Fix: use ImmutableList
@Immutable
data class FeedState(
val posts: ImmutableList<Post>,
val isLoading: Boolean
)Problem: Expensive computation during composition
// Jank: sorting on every recomposition
@Composable
fun SortedList(items: ImmutableList<Item>) {
val sorted = items.sortedBy { it.timestamp } // Runs on every recomposition
LazyColumn {
items(sorted, key = { it.id }) { item -> ItemRow(item) }
}
}
// Fix: use remember or derivedStateOf
@Composable
fun SortedList(items: ImmutableList<Item>) {
val sorted = remember(items) { items.sortedBy { it.timestamp } }
LazyColumn {
items(sorted, key = { it.id }) { item -> ItemRow(item) }
}
}Reducing Layout-Phase Jank
Problem: Intrinsic measurements causing double measure passes
IntrinsicSize.Min and IntrinsicSize.Max force an additional measurement pass. Using them in scrollable lists multiplies the cost.
// Potentially expensive in a LazyColumn
@Composable
fun MatchingHeightRow() {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Text("Left", modifier = Modifier.weight(1f))
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
Text("Right", modifier = Modifier.weight(1f))
}
}
// Alternative: use fixed height or SubcomposeLayout for dynamic sizing
@Composable
fun FixedHeightRow() {
Row(modifier = Modifier.height(48.dp)) {
Text("Left", modifier = Modifier.weight(1f).wrapContentHeight())
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
Text("Right", modifier = Modifier.weight(1f).wrapContentHeight())
}
}Problem: Nested scrollable layouts
A LazyColumn inside a scrollable Column forces the LazyColumn to measure all items, defeating lazy composition.
// Broken: LazyColumn measures all items
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Header()
LazyColumn { // This is measured with infinite height
items(1000) { ItemRow(it) }
}
}
// Fixed: use LazyColumn as the root, embed header as an item
LazyColumn {
item { Header() }
items(1000) { ItemRow(it) }
}Reducing Draw-Phase Jank
Problem: Shader compilation jank (first-frame stutter)
RenderThread compiles shaders on first use, causing one-time jank. This is visible when a new visual element appears for the first time.
Mitigation: use GraphicsLayer caching for complex composables.
@Composable
fun CachedCard(content: @Composable () -> Unit) {
Box(
modifier = Modifier.graphicsLayer {
// Caches the drawing commands, reducing draw-phase work
shadowElevation = 4.dp.toPx()
shape = RoundedCornerShape(8.dp)
clip = true
}
) {
content()
}
}Problem: Large bitmap decoding on the main thread
// Fix: decode bitmaps off the main thread with Coil
@Composable
fun PostImage(url: String) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.size(Size.ORIGINAL)
.build(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f)
)
}Trade-offs
| Optimization | Impact | Cost |
|---|---|---|
| Stable data classes with ImmutableList | Eliminates unnecessary recompositions | Adds dependency on kotlinx-collections-immutable |
remember for derived data | Avoids recomputation per frame | Memory overhead for cached values |
| Fixed heights instead of intrinsics | Removes double measure pass | Less flexible layouts |
graphicsLayer caching | Reduces draw-phase work | Additional GPU memory for render cache |
Failure Modes
| Failure | Symptom | Detection |
|---|---|---|
| All list items recompose on scroll | Consistent jank during scrolling | Layout Inspector recomposition counts |
derivedStateOf recalculates every frame | Derived state is more expensive than the recomposition it prevents | Perfetto trace shows long composition slices |
LazyColumn prefetch causes jank | Stutter when scrolling reaches prefetch boundary | JankStats correlated with scroll state |
| Image loading blocks composition | Frame drops when images appear on screen | Perfetto shows long BitmapFactory.decode on main thread |
Scaling Considerations
- Lazy list item types: use
contentTypeinLazyColumnitems to improve composition reuse. Different item types should have different content types - Composition locality: break large composables into smaller functions. Smaller recomposition scopes mean less work per frame
- Shared element transitions: complex animations during navigation transitions are a common jank source. Profile transitions separately from static screens
- Release mode testing: debug builds disable Compose compiler optimizations. Always benchmark in release mode with R8 enabled
LazyColumn {
items(
items = feedItems,
key = { it.id },
contentType = { it.type } // Helps Compose reuse compositions
) { item ->
when (item) {
is FeedItem.Post -> PostCard(item)
is FeedItem.Ad -> AdCard(item)
is FeedItem.Story -> StoryRow(item)
}
}
}Observability
- JankStats in production: aggregate per-screen jank rates and P95 frame durations
- Perfetto traces in CI: capture traces during Macrobenchmark runs and store as build artifacts
- Compose compiler metrics: check skippability percentages per composable in the build output
- Frame timing histograms: bucket frame durations into under 8ms, 8 to 16ms, 16 to 32ms, over 32ms and track distribution over app versions
Key Takeaways
- Jank in Compose apps usually originates in the composition phase, not layout or draw. Fix stability first
- Use JankStats for production monitoring and Perfetto for root cause analysis
ImmutableList, properkeyusage, andcontentTypeare not optional for list-heavy screens- State reads in the wrong phase (composition vs layout) can multiply the recomposition blast radius
- Always measure in release mode on a low-end device. Debug builds and high-end hardware hide real-world jank
Related: Optimizing RecyclerView vs Compose Lists.
Further Reading
- Debugging Performance Issues in Large Android Apps: A systematic approach to identifying, isolating, and fixing performance bottlenecks in large Android codebases, covering profiling strate...
- Avoiding Hidden Recomposition Traps in Compose: Practical catalog of non-obvious Compose patterns that trigger excessive recomposition, with fixes and measurement strategies for each.
- Diagnosing Battery Drain in Android Apps: A structured methodology for identifying and fixing battery drain in Android apps, covering wake locks, location updates, background work...
Final Thoughts
Jank reduction in Compose is a measurement discipline. The tools exist: JankStats for production, Perfetto for profiling, compiler metrics for build-time auditing. The mistake most teams make is optimizing by intuition instead of by data. Capture a trace, identify the longest slice, fix it, and measure again. Repeat until P95 frame duration is under your target. That is the entire methodology.
Recommended
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.