Measuring and Reducing Jank in Compose Apps

Dhruval Dhameliya·January 19, 2026·8 min read

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
  • LazyColumn recycles compositions but does not recycle views like RecyclerView
  • 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

PhaseWhat HappensJank Cause
CompositionCompose runtime executes composable functions, diffs the slot tableToo many composables recomposing, expensive computations in composables
LayoutMeasures and places each node in the layout treeDeeply nested layouts, intrinsic measurements, repeated measure passes
DrawingRenders the layout tree to the canvasComplex 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"
    }
  }
}
EOF

In 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

OptimizationImpactCost
Stable data classes with ImmutableListEliminates unnecessary recompositionsAdds dependency on kotlinx-collections-immutable
remember for derived dataAvoids recomputation per frameMemory overhead for cached values
Fixed heights instead of intrinsicsRemoves double measure passLess flexible layouts
graphicsLayer cachingReduces draw-phase workAdditional GPU memory for render cache

Failure Modes

FailureSymptomDetection
All list items recompose on scrollConsistent jank during scrollingLayout Inspector recomposition counts
derivedStateOf recalculates every frameDerived state is more expensive than the recomposition it preventsPerfetto trace shows long composition slices
LazyColumn prefetch causes jankStutter when scrolling reaches prefetch boundaryJankStats correlated with scroll state
Image loading blocks compositionFrame drops when images appear on screenPerfetto shows long BitmapFactory.decode on main thread

Scaling Considerations

  • Lazy list item types: use contentType in LazyColumn items 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, proper key usage, and contentType are 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

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