Back to Articles
Jetpack Compose 240fps UI Performance Guide

Jetpack Compose 240fps UI Performance Guide

Frame budget: 4.17ms — one-quarter of the standard 60fps budget (16.67ms). Every unnecessary recomposition, allocation, layout pass, or draw call is a dropped frame.


Table of Contents

  1. The Rendering Pipeline at 240Hz
  2. Requesting 240Hz from the System
  3. The Three Phases and Phase Deferral
  4. Recomposition: The #1 Enemy
  5. Stability System Deep Dive
  6. Strong Skipping Mode
  7. State Management for 240fps
  8. Layout Phase Optimization
  9. Draw Phase Optimization
  10. Modifier System Performance
  11. Animation at 240fps
  12. Scroll and Lazy List Performance
  13. Text Rendering
  14. Image Loading
  15. Memory and Allocation
  16. Overdraw and Layer Management
  17. Baseline Profiles and R8
  18. Profiling and Benchmarking
  19. Master Checklist

1. The Rendering Pipeline at 240Hz

Frame Budget Comparison

Refresh RateFrame BudgetDevices
60Hz16.67msStandard
90Hz11.11msMid-range
120Hz8.33msFlagships (Pixel, Samsung, Nothing)
144Hz6.94msGaming phones
240Hz4.17msUltra-high refresh / gaming / XR

The Pipeline Per Frame

VSync Signal
  -> Choreographer callback
    -> Input handling (~0.2ms)
    -> Animation updates (~0.3ms)
    -> Traversal: Composition -> Layout -> Draw (~2ms target)
  -> Sync RenderNodes to RenderThread (~0.2ms)
  -> RenderThread: GPU command execution (~1.5ms)
  -> SurfaceFlinger: buffer submission
  -> Display

Budget split at 240Hz:

  • Main Thread (composition + layout + draw recording): ~2ms
  • Sync to RenderThread: ~0.2ms
  • RenderThread (GPU commands): ~1.5ms
  • Buffer/overhead: ~0.47ms

Choreographer and VSync

Android’s Choreographer synchronizes all UI work with display VSync signals. Compose’s MonotonicFrameClock on Android wraps Choreographer — it automatically adapts to whatever refresh rate the display operates at. No Compose-specific code changes needed to support higher refresh rates.

RenderThread

The RenderThread is a dedicated thread that takes RenderNode display lists from the main thread and issues GPU commands. When using graphicsLayer{}, transform updates (translation, rotation, scale, alpha) happen on the RenderThread without touching the main thread — the cached display list is reused with only the transform matrix updated.

HWUI Pipeline

Android’s HWUI (Hardware UI) pipeline manages:

  • RenderNode tree mirroring the View/Compose hierarchy
  • Display list recording (draw commands captured, not executed)
  • GPU texture management and upload
  • Hardware layer caching

Each graphicsLayer in Compose creates a RenderNode. RenderNodes save CPU time, not GPU time — the GPU does identical work, but the CPU avoids regenerating drawing commands by reusing cached blocks.


2. Requesting 240Hz from the System

Surface.setFrameRate() (API 30+)

// Request 240Hz from the surface
surfaceView.holder.surface.setFrameRate(
    240f,
    Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
)

Window Display Mode

val window = (context as Activity).window
val display = window.windowManager.defaultDisplay
val modes = display.supportedModes

// Find the highest refresh rate mode
val bestMode = modes.maxByOrNull { it.refreshRate }
window.attributes = window.attributes.apply {
    preferredDisplayModeId = bestMode?.modeId ?: 0
}

Adaptive Refresh Rate (Android 15+, LTPO panels)

Android 15 introduced ARR which decouples VSync rate from display refresh rate:

  • VSync can run at 240Hz while the display refreshes at any divisor
  • The system intelligently adjusts refresh rate based on content cadence
  • frameIntervalNs hint tells the compositor about expected frame cadence

240Hz Reality Check

  • Hardware: Some gaming phones (ASUS ROG, Nubia RedMagic) support 165-240Hz touch sampling, but actual 240Hz display panels are rare. Most flagships max at 120Hz with LTPO (1-120Hz variable).
  • AOSP: Shows 240Hz VSync period examples (4.16ms) with actual frame presentation at divisor rates.
  • Challenge: At 4.17ms, any composition work during animation is likely to cause frame drops. Draw-phase-only animations via graphicsLayer{} become mandatory, not optional.

3. The Three Phases and Phase Deferral

Phase Cost Hierarchy

PhasePurposeWhat It TriggersCost
CompositionBuild/update UI treeLayout + DrawingHighest
LayoutMeasure + placeDrawingMedium
DrawingRender pixelsNothing furtherLowest

Critical insight: Reading state in an earlier phase triggers ALL subsequent phases.

Where to Read State

Where State is ReadPhases Re-runBest For
@Composable body / non-lambda modifierComposition -> Layout -> DrawingContent that changes structure
Modifier.offset { } lambdaLayout -> DrawingPosition-only changes
Modifier.graphicsLayer { } lambdaDrawing onlyVisual transforms
Modifier.drawBehind { } / Canvas { }Drawing onlyColor/visual-only changes

Phase Deferral Examples

// WORST: Composition phase read — triggers ALL three phases
var color by remember { mutableStateOf(Color.Red) }
Box(Modifier.background(color))  // 240 recompositions/sec at 240fps

// MIDDLE: Layout phase read — triggers layout + draw
var offsetX by remember { mutableStateOf(8.dp) }
Text(modifier = Modifier.offset {
    IntOffset(offsetX.roundToPx(), 0)  // Skips composition
})

// BEST: Draw phase read — triggers ONLY draw
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    drawRect(color)  // Skips composition AND layout
}

Lambda State Providers Pattern

// BAD: reads scroll value in parent's composition scope
@Composable
fun SnackDetail() {
    val scroll = rememberScrollState(0)
    Title(snack, scroll.value)  // Composition-phase read every scroll pixel
}

// GOOD: defers read to child via lambda
@Composable
fun SnackDetail() {
    val scroll = rememberScrollState(0)
    Title(snack) { scroll.value }  // Lambda — read deferred
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    Column(
        modifier = Modifier.graphicsLayer {
            translationY = -scrollProvider().toFloat() / 2  // Read in DRAW phase
        }
    ) {
        Text(snack.name)
    }
}

Golden Rule

Read state as late as possible. If it’s for positioning, read it in the layout phase. If it’s for visual appearance (alpha, rotation, color, custom drawing), read it in the draw phase. Lambda modifiers are the mechanism to achieve this.


4. Recomposition: The #1 Enemy

At 240fps, a single unnecessary recomposition costs you ~24% of your frame budget. The most impactful optimizations are those that eliminate recompositions entirely.

Value vs Lambda Modifiers

// BAD: Every frame of animation triggers Composition -> Layout -> Drawing
val angle by animateFloatAsState(targetValue = targetAngle)
Icon(modifier = Modifier.rotate(angle))  // Value overload

// GOOD: Only draw phase runs (zero recomposition)
val angle by animateFloatAsState(targetValue = targetAngle)
Icon(modifier = Modifier.graphicsLayer { rotationZ = angle })  // Lambda overload

Side Effects: Key Selection Is Critical

// BAD: unstable key restarts effect every recomposition
LaunchedEffect(viewModel::loadData) { viewModel.loadData() }

// GOOD: stable key — effect only restarts when userId changes
LaunchedEffect(userId) { viewModel.loadData(userId) }

// GOOD: Unit key — runs once, never restarts
LaunchedEffect(Unit) { viewModel.initialize() }

rememberUpdatedState: Avoid Unnecessary Restarts

@Composable
fun SplashScreen(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {
        delay(3000)
        currentOnTimeout()  // Always calls latest lambda without restarting delay
    }
}

DisposableEffect: Avoid Rapidly Changing Keys

// BAD: key changes every frame during scroll
DisposableEffect(scrollPosition) {  // setup/teardown 240x/sec
    val listener = addScrollListener()
    onDispose { listener.remove() }
}

// GOOD: stable key
DisposableEffect(listState) {
    val listener = addScrollListener(listState)
    onDispose { listener.remove() }
}

Avoid Backwards Writes

// CATASTROPHIC: infinite recomposition loop
@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }
    Text("$count")
    count++  // Writing state AFTER reading it
}

Keep State Granular

// BAD: single large state object — changing any field recomposes everything
@Composable
fun Screen(state: ScreenState) {
    // Title, items, loading indicator ALL recompose even if only isLoading changed
}

// GOOD: separate state per concern
@Composable
fun Screen(viewModel: ScreenViewModel) {
    val title by viewModel.title.collectAsStateWithLifecycle()
    val items by viewModel.items.collectAsStateWithLifecycle()
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()

    TitleBar(title)           // Only recomposes when title changes
    ItemsList(items)          // Only recomposes when items change
    LoadingOverlay(isLoading) // Only recomposes when loading changes
}

movableContentOf: Preserve State Across Layout Changes

@Composable
fun AdaptiveLayout(isVertical: Boolean) {
    val content = remember {
        movableContentOf {
            CheckboxItem("Option A")
            CheckboxItem("Option B")
        }
    }
    if (isVertical) Column { content() }
    else Row { content() }
    // Checkboxes MOVE without recomposition. Checked state preserved.
}

5. Stability System Deep Dive

How the Compiler Determines Stability

Automatically stable:

  • All primitives (Int, Long, Float, Boolean, Char, etc.)
  • String
  • Function types (lambdas) — with caveats under strong skipping
  • Enum classes, sealed classes
  • Data classes where all public properties are val of stable types

Automatically unstable:

  • Any class with var properties
  • Standard Kotlin collections (List, Set, Map) — interfaces whose impl may be mutable
  • Classes from external modules not compiled with Compose compiler

@Immutable vs @Stable

// @Immutable: once constructed, nothing changes. Strongest guarantee.
@Immutable
data class Snack(
    val id: Long,
    val name: String,
    val tags: ImmutableSet<String> = persistentSetOf()
)

// @Stable: properties may change, but Compose is notified via snapshot state.
@Stable
class UiState {
    var isLoading by mutableStateOf(false)
    var items by mutableStateOf<List<Item>>(emptyList())
}

Decision Framework: @Immutable vs @Stable

Use @Immutable when:

  • The class is genuinely immutable (all val, no mutable containers)
  • The class comes from a module not compiled with the Compose compiler
  • You need the compiler to treat it as stable for skipping (equals() comparison)

Use @Stable when:

  • The class wraps mutableStateOf properties
  • New instances with identical data arrive frequently (Room queries, API responses, mapped DTOs) — you need equals() comparison, not reference equality
  • Data sources allocate a new object per item even when nothing changed

Drop both annotations when the ViewModel holds and passes the same reference, or for enums and sealed classes — the compiler already infers these.

The Collections Problem

Standard Kotlin collections are unstable because List<T> is an interface — val list: List<String> = mutableListOf() is legal.

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}

// Before: UNSTABLE — composable never skips
fun HighlightedSnacks(snacks: List<Snack>)

// After: STABLE — composable can skip
fun HighlightedSnacks(snacks: ImmutableList<Snack>)

Alternative: stable wrapper class when you can’t migrate the collection type:

@Immutable
data class SnackCollection(val snacks: List<Snack>)

// STABLE via @Immutable wrapper — composable can skip
fun HighlightedSnacks(snacks: SnackCollection)

Multi-Module Stability

Classes from modules not compiled with Compose compiler are always unstable.

Fix 1: Lightweight annotation dependency

// In your data-layer module (no full Compose runtime needed)
dependencies {
    compileOnly("androidx.compose.runtime:runtime-annotation:1.9.0")
}

Fix 2: Stability configuration file for classes you can’t modify:

// stability_config.conf
java.time.LocalDateTime
java.time.ZonedDateTime
com.datalayer.*
com.datalayer.**
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

Compiler Reports

// build.gradle.kts (per module)
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
./gradlew assembleRelease
# Reports in: <module>/build/compose_compiler/

Key files:

FileContent
module.jsonAggregate stats — target skippable/restartable ratio near 1.0
composables.txtPer-function stability — look for restartable without skippable
classes.txtPer-class stability — one unstable field makes entire class unstable

Reading composables.txt:

restartable skippable scheme("[...]") fun SnackCard(     <-- GOOD
restartable scheme("[...]") fun HighlightedSnacks(       <-- BAD (not skippable)
    unstable snacks: List<Snack>                         <-- root cause

Reading module.json — the big picture:

{
    "skippableComposables": 64,
    "restartableComposables": 76,
    "knownStableArguments": 890,
    "knownUnstableArguments": 30,
    "unknownStableArguments": 1
}

Target skippableComposables / restartableComposables approaching 1.0 — with strong skipping it should be nearly 100%. Watch knownUnstableArguments: each one is a parameter forced into reference-equality (===) comparison.

Default parameter markers in composables.txt:

  • @static — constant default, no state read (good)
  • @dynamic — default reads observable state (CompositionLocal, remember) — expected for theme values like MaterialTheme.colorScheme.primary, but investigate unexpected occurrences

classes.txt — one unstable field poisons the class:

unstable class Snack {
    stable val id: Long
    stable val name: String
    unstable val tags: Set<String>       <-- root cause
    <runtime stability> = Unstable
}

A single unstable field makes the entire class unstable. Here Set<String> (an interface) is the culprit — swap for ImmutableSet.

For classes you cannot modify (third-party, Java stdlib), list them in the stability configuration file. This is a contract with the compiler — listing a class that IS mutable causes missed recompositions.


6. Strong Skipping Mode

Enabled by default since Kotlin 2.0.20. Two fundamental changes:

  1. All restartable composables become skippable regardless of parameter stability
  2. All lambdas inside composable functions are automatically memoized

Parameter Comparison Rules

Parameter TypeOld BehaviorStrong Skipping
Stable paramsSkip via equals()Skip via equals() (unchanged)
Unstable paramsAlways recomposeSkip if same instance (===)
Lambdas (unstable captures)Always recomposeAuto-memoized with remember

What Lambda Auto-Memoization Generates

The compiler rewrites every lambda inside a composable, keying the remember on the lambda’s captures:

// What you write:
val lambda = { use(unstableObject); use(stableObject) }

// What the compiler generates:
val lambda = remember(unstableObject, stableObject) {
    { use(unstableObject); use(stableObject) }
}

This is why a lambda capturing a per-frame-changing value still produces a fresh instance every frame — the remember key changes. Capture stable identifiers (an id, not the whole object) to maximize memoization hits.

Critical Gotcha: Mutable Collections NOT Fixed

Strong skipping compares unstable params by reference (===). Same MutableList reference + mutated contents = composable will NOT recompose:

// BUG: same reference, different content
list.add("Bar")  // Won't trigger recomposition

// FIX: new reference
list = list.toMutableList().apply { add("Bar") }

Critical Gotcha: LazyListScope Lambdas NOT Auto-Memoized

LazyListScope is NOT a composable scope — lambdas inside aren’t auto-memoized:

// PROBLEM: onAction lambda NOT auto-memoized
LazyColumn {
    items(items) { item ->
        ItemCard(onAction = { viewModel.handleAction(item.id) })
    }
}

// FIX: manually remember
LazyColumn {
    items(items, key = { it.id }) { item ->
        val onAction = remember(item.id) { { viewModel.handleAction(item.id) } }
        ItemCard(onAction = onAction)
    }
}

Opting Out

@NonSkippableComposable   // Prevent skipping
@Composable fun AlwaysRecompose() { }

val lambda = @DontMemoize { }  // Prevent memoization

When @Stable Still Matters

Keep @Stable when new instances with the same data arrive frequently (Room queries, API responses, mapped DTOs) — you need equals() comparison, not reference equality.


7. State Management for 240fps

derivedStateOf: Reduce Recomposition Frequency

Only triggers recomposition when its computed result changes — not when inputs change.

// BAD: recomposes on every scroll pixel
val showButton = listState.firstVisibleItemIndex > 0

// GOOD: recomposes only when boolean flips
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

When NOT to use: When the derived value changes at the same rate as the input:

// WRONG: no benefit, adds overhead
val fullName by remember { derivedStateOf { "$firstName $lastName" } }

// CORRECT: just use remember
val fullName = remember(firstName, lastName) { "$firstName $lastName" }

Always wrap with remember:

// BAD: creates new derivedStateOf every recomposition
val filtered by derivedStateOf { items.filter { it.isActive } }

// GOOD: single instance
val filtered by remember { derivedStateOf { items.filter { it.isActive } } }

Advanced — structuralEqualityPolicy for derived values that produce equal-content but distinct-reference results, so recomposition fires only on structural change:

val expensiveResult by remember {
    derivedStateOf(structuralEqualityPolicy()) {
        computeExpensiveList(input)  // Triggers only if list CONTENT changes
    }
}

snapshotFlow: Bridge to Flow Without Recomposition

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .debounce(300)
        .collect { index -> analyticsTracker.trackScrollPosition(index) }
}

derivedStateOf vs snapshotFlow

AspectderivedStateOfsnapshotFlow
CreatesState<T>Flow<T>
PurposeUI recomposition optimizationSide effects, ViewModel communication
Used inComposable bodyLaunchedEffect / coroutines
Flow operatorsNot availableFull Flow API (debounce, filter, etc.)
Triggers recompositionYes (when result changes)No

remember: Cache Expensive Computations

val sortedContacts = remember(contacts, comparator) {
    contacts.sortedWith(comparator)  // Computed once until inputs change
}
val paint = remember { Paint().apply { isAntiAlias = true } }

8. Layout Phase Optimization

Single-Pass Layout Enforcement

Compose enforces single-pass layout. Each node measures children once, passing constraints down and sizes back up.

Intrinsic Measurements Are Expensive

Intrinsic measurements (IntrinsicSize.Min, IntrinsicSize.Max) trigger additional layout passes:

// EXPENSIVE: extra pass
Row(modifier = Modifier.height(IntrinsicSize.Min)) { ... }

// CHEAPER: explicit size
Row(modifier = Modifier.height(48.dp)) { ... }

SubcomposeLayout: Avoid in Hot Paths

SubcomposeLayout runs synchronous composition during measurement — the most expensive phase inside the already-expensive layout phase.

Measured impact (real chat UI, HackerNoon June 2025):

MetricSubcomposeLayoutStandard LayoutImprovement
P50 Frame Duration6.3ms5.9ms6.7%
P90 Frame Duration11.0ms10.5ms4.7%
P95 Frame Duration12.9ms12.3ms4.8%
P99 Frame Duration16.2ms15.0ms8.0%
P99 Frame Overrun1.4ms0.2ms85.7%

Also:

  • No intrinsic measurement support — throws if intrinsics are requested
  • Slot reuse isn’t free — still requires reactivation and state reconciliation
  • Blocks pausable composition (can’t benefit from Compose 1.9+ optimization)

When to Use Each Layout Approach

ApproachWhen
Standard LayoutChildren are known at composition time
Intrinsic MeasurementsNeed child sizes but not conditional composition
SubcomposeLayoutComposition truly depends on measurement constraints (lazy lists, adaptive layouts)
BoxWithConstraintsSparingly — never inside list items or frequently recomposed composables

Replace with standard Layout + Ref pattern:

val textLayoutRef = remember { Ref<TextLayoutResult>() }
Layout(
    content = {
        Text(text = message.text, onTextLayout = { textLayoutRef.value = it })
        MessageFooter(message)
    }
) { measurables, constraints ->
    val textPlaceable = measurables[0].measure(constraints.copy(maxWidth = maxWidthPx))
    val footerPlaceable = measurables[1].measure(constraints)
    // Single-pass positioning
}

Flatten Deep Hierarchies

// DEEP: Multiple layout passes through tree
Column { Row { Box { Text("A") }; Box { Text("B") } } }

// FLAT: Single layout pass
Layout(content = content) { measurables, constraints ->
    val placeables = measurables.map { it.measure(constraints) }
    layout(constraints.maxWidth, constraints.maxHeight) {
        placeables[0].placeRelative(0, 0)
        placeables[1].placeRelative(placeables[0].width, 0)
    }
}

Custom Layout vs Box/Column/Row Tradeoffs

ComposableOverheadNotes
Column/RowMinimalStraightforward single-pass measure strategy
BoxSlightly moreStacking/overlapping, still very efficient
Custom LayoutCan be lower for complex UIsFlattens deep hierarchies into a single Layout call
ConstraintLayoutHigherConstraint resolution cost, but reduces nesting

Compose’s single-pass model means deep nesting is far cheaper than in the View system — but at 4.17ms, flattening hot composables into a custom Layout still removes measurable per-frame overhead.

Separate Measurement from Placement

When only position changes (not size), defer state reads to placement:

Layout(content = { content() }) { measurables, constraints ->
    val placeables = measurables.map { it.measure(constraints) }
    layout(constraints.maxWidth, constraints.maxHeight) {
        placeables.forEachIndexed { i, placeable ->
            placeable.placeRelative(0, i * itemHeight + scrollOffset())  // Only placement affected
        }
    }
}

9. Draw Phase Optimization

drawWithCache: Cache Objects Between Frames

At 240fps, eliminating per-frame allocations is critical. drawWithCache caches Path, Brush, Shader objects until size or read state changes:

Modifier.drawWithCache {
    // Created ONCE and cached
    val path = Path().apply {
        moveTo(0f, 0f); lineTo(size.width, 0f); lineTo(size.width, size.height); close()
    }
    val brush = Brush.verticalGradient(listOf(Color.Red, Color.Blue))

    onDrawBehind {
        // Only transforms change per frame — path and brush reused
        translate(left = 0f, top = animationValue.value) {
            drawPath(path, brush)
        }
    }
}

drawWithContent: Drawing Order Control

drawWithContent lets you interleave custom drawing before or after the composable’s own content — useful for overlays and spotlight effects without an extra layer:

Modifier.drawWithContent {
    drawContent()                    // composable content first
    drawRect(                        // overlay drawn on top
        Brush.radialGradient(
            listOf(Color.Transparent, Color.Black),
            center = pointerOffset,
            radius = 100.dp.toPx(),
        )
    )
}

Compositing Strategies

// AUTO (default): offscreen buffer if alpha < 1.0 or RenderEffect set
Modifier.graphicsLayer { alpha = 0.5f }

// OFFSCREEN: always rasterize to texture. Required for BlendMode operations
Modifier.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }

// MODULATE_ALPHA: MOST EFFICIENT — no offscreen buffer, per-draw-instruction alpha
// Only correct for non-overlapping content
Modifier.graphicsLayer {
    compositingStrategy = CompositingStrategy.ModulateAlpha
    alpha = 0.75f
}

Offscreen Layer Trade-offs

Offscreen layers render RenderNode commands to a GPU texture; subsequent frames only issue “draw this texture,” which is very fast. But:

  • They cost RAM and bandwidth
  • If the layer changes frequently, you do the work twice — regenerate the texture and copy it to screen

Use offscreen for stable, expensive-to-recompose layouts; avoid it for rapidly changing content.

Capture a Composable to Bitmap

rememberGraphicsLayer records the draw pass once and exports it without re-running composition:

val graphicsLayer = rememberGraphicsLayer()
Box(
    modifier = Modifier
        .drawWithContent {
            graphicsLayer.record { this@drawWithContent.drawContent() }
            drawLayer(graphicsLayer)
        }
        .clickable {
            coroutineScope.launch {
                val bitmap = graphicsLayer.toImageBitmap()
            }
        }
)

graphicsLayer Properties (All Draw-Phase Only)

Modifier.graphicsLayer {
    translationX = animatedX
    translationY = animatedY
    rotationZ = animatedAngle
    scaleX = animatedScale
    scaleY = animatedScale
    alpha = animatedAlpha
    shadowElevation = animatedElevation
    clip = true
    shape = RoundedCornerShape(animatedCornerRadius)
}

10. Modifier System Performance

Three Custom Modifier APIs

APIPerformanceWhy
ModifierNodeElement + Modifier.NodeBestPersistent node, no composition overhead
Modifier.Element (legacy)MiddleSimple interface
Modifier.composed {}WorstCreates mini-composition scope per instance, GC pressure

The clickable modifier migrated from composed to Modifier.Node, reporting ~80% performance improvement. Modifier.composed is slow because it creates a mini-composition scope per instance, its lambda can’t be cached, its equality comparison is broken (so it can never skip), and it generates short-lived garbage.

CombinedModifier Internals

Modifier.then() builds a recursive CombinedModifier (a cons list), not a flat list. Each Modifier.Element is a single behavior (layout, drawing, gesture). Elements added first are applied first — which is why ordering changes both correctness and cost.

Modifier.Node Architecture

// 1. Factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

// 2. Element — ephemeral config, compared via equals
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)
    override fun update(node: CircleNode) { node.color = color }
}

// 3. Node — persistent, survives recomposition
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() { drawCircle(color) }
}

Combined Node with Manual Invalidation

class SampleNode(var color: Color, var size: IntSize) :
    DelegatingNode(), LayoutModifierNode, DrawModifierNode {

    override val shouldAutoInvalidate: Boolean get() = false

    fun update(color: Color, size: IntSize) {
        if (this.color != color) { this.color = color; invalidateDraw() }      // Only draw
        if (this.size != size) { this.size = size; invalidateMeasurement() }   // Only layout
    }
}

Node Interface Selection

PurposeInterfaceUse Case
DrawingDrawModifierNodeBadges, overlays, decorations
Sizing/LayoutLayoutModifierNodeAspect ratio, min touch size
Parent dataParentDataModifierNodeWeights, alignment data
AccessibilitySemanticsModifierNodeLabels, merged controls
GesturesPointerInputModifierNodeSwipe, drag detection
CoordinatesGlobalPositionAwareModifierNodeAnchors, tooltips
Composition localsCompositionLocalConsumerModifierNodeRTL, density-aware logic

Node Lifecycle and Coroutines

A node’s coroutineScope is tied to attachment — launch animations in onAttach, and they are automatically cancelled in onDetach:

override fun onAttach() {
    coroutineScope.launch {
        animatable.animateTo(1f, infiniteRepeatable(tween(1000)))
    }
    // Cancelled automatically on onDetach()
}

Modifier Chain Optimization

// BAD: new modifier chain allocated every recomposition
@Composable
fun AnimatedItem() {
    Box(modifier = Modifier.padding(16.dp).fillMaxWidth().height(48.dp).background(Color.White))
}

// GOOD: allocated once, reused
val itemModifier = Modifier.padding(16.dp).fillMaxWidth().height(48.dp).background(Color.White)

@Composable
fun AnimatedItem() {
    Box(modifier = itemModifier)
}

Ordering matters: Layout (size/constraints) -> Appearance (background, clip) -> Interactions (clickable, pointer input).


11. Animation at 240fps

API Performance Ranking

APIRecomp CostBest For
Animatable + graphicsLayer{}None (draw-only)Gesture-driven, physics-based
animate*AsState + graphicsLayer{}None (draw-only)Simple state transitions
InfiniteTransition + graphicsLayer{}None (draw-only)Continuous effects
Any API without lambda modifierEvery frameAvoid at 240fps

The Mandatory Pattern

// This animation pattern is 240fps-safe:
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
    animatable.animateTo(1f, animationSpec = tween(500))
}
Box(modifier = Modifier.graphicsLayer {
    alpha = animatable.value     // Draw phase ONLY
    scaleX = animatable.value
    scaleY = animatable.value
})

animate*AsState Internals

animate*AsState is the simplest API — it creates and remembers an Animatable at the call site internally. It is optimized for single-value, state-driven animations with minimal overhead, but it cannot be cancelled at runtime until removed from the tree, and it triggers recomposition each frame unless its read is deferred via graphicsLayer{}.

Animatable: The Low-Level Control Surface

Animatable is the core coroutine-based API. It has the lowest overhead when used directly and exposes the levers animate*AsState hides:

val offsetX = remember { Animatable(0f) }

offsetX.snapTo(touchX)                              // zero-latency sync with touch

offsetX.animateTo(                                  // spring physics
    targetValue = 0f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioLowBouncy,
        stiffness = Spring.StiffnessLow
    )
)

offsetX.animateDecay(                               // fling, platform-matched physics
    initialVelocity = flingVelocity,
    animationSpec = splineBasedDecay(density)
)

offsetX.updateBounds(lowerBound = 0f, upperBound = maxOffset)
  • snapTo provides zero-latency state synchronization with touch events
  • animateDecay uses spline-based decay matching platform fling physics
  • updateBounds clamps the animation range
  • Spring animations handle interruptions smoothly with velocity continuity guaranteed — an in-flight animation retargeted mid-flight carries its current velocity into the new spring, so gesture handoff never visibly stutters

Text Animation

Text(
    text = "Animated",
    style = TextStyle(textMotion = TextMotion.Animated),  // Avoids layout recalculation
    modifier = Modifier.graphicsLayer {
        scaleX = animatedScale; scaleY = animatedScale
        translationY = animatedOffset
    }
)

Custom Physics Loop with withFrameNanos

LaunchedEffect(Unit) {
    var prev = withFrameNanos { it }
    while (isActive) {
        withFrameNanos { now ->
            val dt = (now - prev) / 1_000_000_000f
            prev = now
            // Frame-rate-independent physics
            velY += gravity * dt
            ballY += velY * dt
            if (ballY > boundary) { ballY = boundary; velY = -velY * bounce }
        }
    }
}
Canvas(Modifier.fillMaxSize()) {
    drawCircle(Color.Red, radius = 20f, center = Offset(ballX, ballY))
}

withFrameNanos suspends until the next frame and delegates to the MonotonicFrameClock in the coroutine context (AndroidUiFrameClock wraps Choreographer). Key properties:

  • Frame time values are strictly monotonically increasing
  • Values may be normalized to the target frame time, not necessarily wall-clock “now”
  • Throws IllegalStateException if no MonotonicFrameClock is in the CoroutineContext
  • In UI tests, continuations resumed inside the callback dispatch only after all frame callbacks complete

TargetBasedAnimation: Manual Playback

When you need to drive an animation’s clock yourself (custom sequencing, scrubbing), TargetBasedAnimation exposes value-from-nanos directly:

val anim = remember {
    TargetBasedAnimation(
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        typeConverter = Float.VectorConverter,
        initialValue = 0f,
        targetValue = 300f
    )
}
var animValue by remember { mutableFloatStateOf(0f) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }
    do {
        val playTime = withFrameNanos { it } - startTime
        animValue = anim.getValueFromNanos(playTime)
    } while (!anim.isFinishedFromNanos(playTime))
}

Box(Modifier.graphicsLayer { translationY = animValue })

Frame Rate Control: Skip Frames for Non-Critical Animations

// Animate every 4th frame (60fps at 240Hz) to save power
class SkippingFrameClock(
    private val delegate: MonotonicFrameClock,
    private val frameSkip: Int = 3
) : MonotonicFrameClock {
    override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R {
        repeat(frameSkip) { delegate.withFrameNanos { } }
        return delegate.withFrameNanos(onFrame)
    }
}

LookaheadScope (Stable since Compose 1.8)

Pre-calculates target layout in a lookahead pass, then animates toward it:

LookaheadScope {
    Box(
        modifier = Modifier
            .animateBounds(animationSpec = spring(
                dampingRatio = Spring.DampingRatioLowBouncy,
                stiffness = Spring.StiffnessMediumLow
            ))
            .then(if (expanded) Modifier.fillMaxWidth().height(300.dp)
                  else Modifier.width(100.dp).height(100.dp))
    )
}

At 240fps, the extra lookahead pass must fit within 4.17ms.

Two-pass system:

  1. Lookahead pass — all layouts determine their target/destination measurements and positions
  2. Approach pass — layouts run measurement/placement approach logic to gradually reach the destination

SubcomposeLayout-based components (TabRow, Scaffold, BoxWithConstraints) now work correctly under lookahead. Combined with pausable composition (1.9+), lookahead measurements can be paused and resumed across frames.

approachLayout for custom interpolation — drive the constraints yourself between current and lookahead size:

Modifier.approachLayout(
    isMeasurementApproachInProgress = { lookaheadSize ->
        currentAnimatedSize != lookaheadSize       // true while animating
    }
) { measurable, _ ->
    val animatedConstraints = Constraints.fixed(
        animatedWidth.roundToInt(),
        animatedHeight.roundToInt()
    )
    val placeable = measurable.measure(animatedConstraints)
    layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}

SharedTransitionScope

Resize ModePerformanceUse Case
ScaleToBoundsFaster — no relayoutText, fixed-aspect content
RemeasureToBoundsSlower — re-measures every frameAvoid at 240fps

SharedTransitionScope is built on top of LookaheadScope. At 240fps, RemeasureToBounds can trigger up to 240 measurements/sec — unlikely to fit in 4.17ms for complex content — so ScaleToBounds is strongly preferred.

Overlay rendering: shared elements render into a SharedTransitionScope overlay layer during transitions, drawn on top of all other content. Use renderInSharedTransitionScopeOverlay() for elements like bottom bars that must stay on top.

Constraints to plan around:

  • No View/Compose interop support — no Dialog, no ModalBottomSheet
  • ContentScale is not animated (snaps to the end value)
  • Clean up shared elements after the transition by observing SharedTransitionScope.isTransitionActive
  • Keep shared-element content simple — avoid complex composable trees inside the shared element

12. Scroll and Lazy List Performance

Mandatory Optimizations

LazyColumn(
    state = rememberLazyListState(),
) {
    items(
        items = items,
        key = { it.id },                    // 1. Stable keys — CRITICAL
        contentType = { it.type },          // 2. ContentType for recycling
    ) { item ->
        ItemCard(item)
    }
}

Compose keeps a limited composition cache per contentType. When a recycled slot’s type matches the incoming item, the composition slot is reused (ViewHolder-style) instead of fully recomposed — critical for heterogeneous lists where each row type has different structure:

contentType = { message ->
    when (message) {
        is Message.TextMessage -> "text"
        is Message.ImageMessage -> "image"
        is Message.VideoMessage -> "video"
    }
}

LazyLayoutCacheWindow (Compose 1.9+)

val cacheWindow = LazyLayoutCacheWindow(ahead = 150.dp, behind = 100.dp)
val state = rememberLazyListState(cacheWindow = cacheWindow)

At 240fps, the viewport shifts significantly per frame during fast flings. Larger cache windows ensure items are pre-composed before entering the viewport.

Nested Prefetch

LazyColumn {
    items(sections, key = { it.id }) { section ->
        val rowState = rememberLazyListState(
            prefetchStrategy = LazyListPrefetchStrategy(nestedPrefetchItemCount = 6)
        )
        LazyRow(state = rowState) { ... }
    }
}

Pausable Composition (Compose 1.9+, Default December 2025)

The runtime monitors frame time budgets and pauses composition work when time runs out, resuming in the next frame. Combined with CacheWindow APIs, scroll jank dropped to 0.2% in internal benchmarks — matching View system performance.

Nested Scrolling Architecture

Compose is nested-scroll-by-default: every scrollable participates in the chain via NestedScrollConnection (parent) and NestedScrollDispatcher (child). The four hooks let a parent intercept scroll and fling around its child:

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val consumed = consumeHeaderCollapse(available.y)   // before child
            return Offset(0f, consumed)
        }
        override fun onPostScroll(
            consumed: Offset, available: Offset, source: NestedScrollSource
        ): Offset = Offset.Zero                                  // leftover after child

        override suspend fun onPreFling(available: Velocity): Velocity =
            Velocity.Zero                                        // before child fling

        override suspend fun onPostFling(
            consumed: Velocity, available: Velocity
        ): Velocity {
            val decay = splineBasedDecay<Float>(density)
            AnimationState(0f, available.y).animateDecay(decay) {
                childScrollState.dispatchRawDelta(value)
            }
            return available
        }
    }
}
Box(Modifier.nestedScroll(nestedScrollConnection)) { LazyColumn { /* ... */ } }

dispatchRawDelta vs scrollBy: use dispatchRawDelta when scrolling from inside a NestedScrollConnection so the offset is not re-dispatched through the chain (which would infinite-loop).

SnapFlingBehavior and Pager Fling

HorizontalPager(
    state = pagerState,
    flingBehavior = PagerDefaults.flingBehavior(
        state = pagerState,
        pagerSnapDistance = PagerSnapDistance.atMost(3),
        lowVelocityAnimationSpec = tween(durationMillis = 500),
        highVelocityAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow),
        snapAnimationSpec = spring(stiffness = Spring.StiffnessMedium)
    )
)

Custom Fling Behavior

class CustomFlingBehavior(
    private val decaySpec: DecayAnimationSpec<Float>,
    private val velocityMultiplier: Float = 1.0f,
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        val velocity = initialVelocity * velocityMultiplier
        var lastValue = 0f
        AnimationState(0f, velocity).animateDecay(decaySpec) {
            val delta = value - lastValue
            lastValue = value
            val consumed = scrollBy(delta)
            if (abs(delta - consumed) > 0.5f) cancelAnimation()
        }
        return 0f
    }
}

Velocity-Tracking Fling Behavior

Expose the live fling velocity so composables can drive draw-phase effects (e.g. motion blur, speed-based scaling) off it:

class VelocityTrackingFlingBehavior(
    private val decaySpec: DecayAnimationSpec<Float>,
) : FlingBehavior {
    var currentVelocity by mutableFloatStateOf(0f)
        private set

    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        var lastValue = 0f
        currentVelocity = initialVelocity
        AnimationState(0f, initialVelocity).animateDecay(decaySpec) {
            val delta = value - lastValue
            lastValue = value
            val consumed = scrollBy(delta)
            currentVelocity = velocity
            if (abs(delta - consumed) > 0.5f) cancelAnimation()
        }
        currentVelocity = 0f
        return 0f
    }
}

13. Text Rendering

TextMeasurer with LRU Cache

val textMeasurer = rememberTextMeasurer(cacheSize = 20)
val measured = textMeasurer.measure(
    text = AnnotatedString("Measure Once"),
    style = TextStyle(fontSize = 16.sp),
    constraints = Constraints(maxWidth = maxWidthPx)
)
Canvas(Modifier.fillMaxSize()) {
    drawText(measured, topLeft = Offset(10f, 50f))  // Bypasses Compose layout
}
  • TextMeasurer holds an internal LRU cache
  • Parameters like color, brush, shadow are ignored during layout — enabling cache hits when only non-layout attributes change
  • Even a slight change in fontSize, maxLines, or one character creates a distinct cache entry

Background Text Prefetch (Google I/O 2025)

Text layout caches can now be pre-warmed on a background thread, eliminating jank from cold text measurement during scrolling.

AnnotatedString for Styled Content (Single Pass)

Building mixed styles into one AnnotatedString lays out in a single pass — far cheaper than stacking multiple Text composables for inline style changes:

val styledText = buildAnnotatedString {
    append("Normal ")
    withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.Red)) {
        append("Bold & Red")
    }
}
Text(styledText)

Preloaded FontFamily

val customFont = FontFamily(
    Font(R.font.roboto_regular, FontWeight.Normal),
    Font(R.font.roboto_bold, FontWeight.Bold)
)
// Use consistently to avoid re-loading

14. Image Loading

API Comparison

ComposableSubcompositionPerformance
AsyncImageNoBest
rememberAsyncImagePainterNoGood
SubcomposeAsyncImageYesWorst

Never use SubcomposeAsyncImage inside LazyColumn — subcomposition during measurement.

Coil 3 Performance

v3.4.0: runtime 25-40% faster, allocations reduced 35-48%.

Painter Stability

Painter is explicitly NOT marked @Stable. Passing it as a parameter causes unnecessary recompositions:

// BAD: Painter is unstable
fun Avatar(painter: Painter) { Image(painter = painter, ...) }

// GOOD: Pass URL
fun Avatar(url: String) { AsyncImage(model = url, ...) }

Bitmap.prepareToDraw()

Uploads texture to GPU before first draw. Most image libraries do this automatically; Coil had a bug where this wasn’t called (fixed in recent versions).

Coil Best Practices

  1. Scale down images — load at display size, not original resolution; AsyncImage auto-downsamples to the optimal load size
  2. Use WEBP — smaller than JPEG/PNG
  3. Lazy layouts free memory for off-screen images
  4. Prefer vectors over bitmaps where they don’t pixelate
  5. Share a single ImageLoader instance across the app

15. Memory and Allocation

At 240fps, GC pauses are frame-killers. A minor GC pause of 2-3ms consumes over half your frame budget.

Avoid Allocations in Hot Paths

Composition phase:

  • remember {} caches objects across recompositions — use it for any object creation
  • Lambda allocations inside composables are handled by strong skipping (auto-memoized)
  • But LazyListScope lambdas are NOT auto-memoized — manually remember them

Layout phase:

  • Don’t create Constraints, IntOffset, IntSize objects in lambda modifiers unnecessarily
  • Reuse measurement results

Draw phase:

  • Use drawWithCache to cache Path, Brush, Shader, Paint objects
  • Don’t allocate Offset, Size, Color in hot draw loops — use drawCircle(color, radius, center) with primitives where possible

Avoid Autoboxing

// BAD: Int gets boxed to Integer in generic State<T>
var count by remember { mutableStateOf(0) }  // State<Int> -> boxing

// GOOD: primitive-aware state holder
var count by remember { mutableIntStateOf(0) }   // No boxing
var progress by remember { mutableFloatStateOf(0f) }
var enabled by remember { mutableStateOf(false) }  // Boolean is fine, small type

Value Classes for Wrapper Types

@JvmInline
value class UserId(val value: String)

@JvmInline
value class Pixels(val value: Float)

At runtime, these are erased to the underlying type — zero allocation overhead.

Lambda Capturing

// Captures `item` — allocates closure object
items.forEach { item ->
    Child(onClick = { viewModel.onClick(item) })  // New lambda per item per recomposition
}

// With strong skipping in @Composable scope: auto-memoized
// In LazyListScope: manually remember
val onClick = remember(item.id) { { viewModel.onClick(item) } }

Offload Computation

@Composable
fun HeavyScreen(data: List<RawItem>) {
    var result by remember { mutableStateOf<List<ProcessedItem>>(emptyList()) }
    LaunchedEffect(data) {
        result = withContext(Dispatchers.Default) {
            data.map { processItem(it) }  // Off main thread
        }
    }
    LazyColumn { items(result, key = { it.id }) { ItemRow(it) } }
}

Dispatchers.Main.immediate vs Dispatchers.Main

  • Dispatchers.Main.immediate dispatches synchronously if already on main thread (avoids queue round-trip)
  • Dispatchers.Main always posts to the message queue (adds latency)
  • Compose internally uses Dispatchers.Main.immediate for snapshotFlow and recomposition triggers

StrictMode for Detection

StrictMode.setThreadPolicy(
    StrictMode.ThreadPolicy.Builder()
        .detectAll()
        .penaltyLog()
        .build()
)
// Watch logcat for disk reads, network calls on main thread

16. Overdraw and Layer Management

Debug GPU Overdraw

Enable: Settings -> Developer options -> Debug GPU overdraw

ColorOverdrawSeverity
True color0xOK
Blue1xAcceptable
Green2xWatch
Pink3xProblematic
Red4x+Critical

Reduce Overdraw

// MORE OVERDRAW: nested backgrounds
Box(Modifier.background(Color.White)) {
    Column(Modifier.background(Color.White)) { Text("Content") }  // Redundant!
}

// LESS OVERDRAW: single background
Column(Modifier.background(Color.White)) { Text("Content") }

// LESS OVERDRAW: opaque > transparent
Box(Modifier.background(Color(0xFF808080)))  // Better than Color.Black.copy(alpha = 0.5f)

Layer Optimization

// Every graphicsLayer creates a RenderNode — has overhead
// Use ONLY when:
// - Content changes position/scale during animations
// - Complex layouts need isolation
// - BlendMode operations require offscreen compositing
// - Scrolling items benefit from RenderNode reuse

// DON'T: graphicsLayer on static content that never animates
// DO: graphicsLayer for animated transforms
Box(modifier = Modifier.graphicsLayer { translationY = scrollOffset.value })

zIndex Stacking Order

Modifier.zIndex controls draw order within a parent — lower draws first, higher draws on top. Use it to keep stacking intentional so obscured layers don’t add overdraw, instead of reordering composables:

Box {
    Surface(modifier = Modifier.zIndex(0f)) { /* background */ }
    Surface(modifier = Modifier.zIndex(1f)) { /* foreground */ }
}

Elevation/Shadow Cost

Shadows require additional drawing passes. Prefer padding/borders/background-color for visual hierarchy over elevation at 240fps.


17. Baseline Profiles and R8

Baseline Profiles

Compose is a library — not pre-compiled on device. Without profiles, JIT must compile hotspots on first use.

Impact: 30%+ overall performance improvement. The Google Play Store reported a 40% reduction in rendering time after adopting baseline profiles.

@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generateProfile() = rule.collect(
        packageName = "com.example.app",
        includeInStartupProfile = true,
    ) {
        startActivityAndWait()
        device.findObject(By.res("main_list")).apply {
            repeat(3) { fling(Direction.DOWN); device.waitForIdle() }
        }
        device.findObject(By.res("item_0")).click()
        device.waitForIdle()
    }
}

Compose ships a default baseline profile. Layer your app-specific profile on top.

Startup Profiles

A startup profile is a subset of the baseline profile that optimizes DEX layout: classes used during startup are packed into the first classes.dex, reducing file loads. Mark startup-critical paths with includeInStartupProfile = true — exercising just startActivityAndWait() is enough to capture the startup profile.

R8 Configuration

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),  // NOT proguard-android.txt
                "proguard-rules.pro"
            )
        }
    }
}

Use proguard-android-optimize.txt — Disney+ reported 30% faster startup and 25% fewer ANRs after switching.

Ensure R8 full mode is on. It is the default; remove any opt-out left in gradle.properties:

# gradle.properties — delete this line if present
# android.enableR8.fullMode=false

Full-mode optimizations relevant to Compose: tree shaking of unreachable code paths, method inlining of small composables, class merging, constant propagation into animation specs, and enum unboxing. On AGP 9.0+, proguard-android.txt (with -dontoptimize) is deprecated and optimized resource shrinking is automatic with isShrinkResources = true.

DO NOT add broad keep rules:

# DON'T — kills optimization
-keep class androidx.compose.** { *; }

Compose libraries ship embedded R8 rules. Trust them.


18. Profiling and Benchmarking

Composition Tracing

dependencies {
    implementation("androidx.compose.runtime:runtime-tracing:1.10.5")
}
// System traces now automatically include composable function names

Macrobenchmark

// benchmark/build.gradle.kts
plugins { id("com.android.test") }
dependencies {
    implementation("androidx.benchmark:benchmark-macro-junit4:1.5.0")
    implementation("androidx.test.ext:junit:1.2.1")
}
android {
    targetProjectPath = ":app"
    experimentalProperties["android.experimental.self-instrumenting"] = true
}
@Test
fun scrollList() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(
        FrameTimingMetric(),
        TraceSectionMetric("LazyColumn.compose"),  // custom trace section
    ),
    compilationMode = CompilationMode.Partial(
        baselineProfileMode = BaselineProfileMode.Require
    ),
    iterations = 10,
    startupMode = StartupMode.WARM,
) {
    startActivityAndWait()
    val list = device.findObject(By.res("item_list"))
    list.setGestureMargin(device.displayWidth / 5)
    repeat(5) { list.fling(Direction.DOWN); device.waitForIdle() }
}

Profile GPU Rendering Bar Colors

Developer Options → Profile GPU rendering → “On screen as bars.” Each vertical bar is one frame; for 240fps target bars under 4.17ms. Color segments (bottom to top):

ColorStageTall segment means
BlueMeasure/Draw — building DisplayListsMany views invalidated or complex onDraw
PurpleSync & Upload — RenderNodes to RenderThread, bitmap uploadHeavy texture uploads
RedExecute/Issue commandsHigh DisplayList complexity
OrangeProcess / GPU waitToo much GPU work (overdraw)

Perfetto Trace Capture

adb shell perfetto -c - --txt \
  -o /data/misc/perfetto-traces/trace.perfetto-trace <<EOF
buffers: { size_kb: 63488, fill_policy: RING_BUFFER }
data_sources: {
    config {
        name: "linux.ftrace"
        ftrace_config {
            ftrace_events: "ftrace/print"
            atrace_categories: "view"
            atrace_categories: "dalvik"
            atrace_categories: "graphics"
            atrace_apps: "your.package.name"
        }
    }
}
duration_ms: 10000
EOF

Custom TraceMetric with SQL

TraceMetric runs SQL directly against the Perfetto trace to extract metrics the built-in metrics don’t cover:

class CustomScrollMetric : TraceMetric() {
    override fun getResult(
        captureInfo: CaptureInfo,
        traceSession: PerfettoTraceProcessor.Session
    ): List<Measurement> {
        val query = """
            SELECT dur / 1000000.0 as dur_ms
            FROM slice
            WHERE name LIKE 'Choreographer#doFrame%'
        """
        // Process query results into Measurement list
    }
}

Key Metrics at 240fps

adb shell dumpsys gfxinfo <package>
# frameDurationCpuMs < 4.0   (target)
# frameOverrunMs < 0          (negative = had time left)

What to Look For in Perfetto

  1. Main thread slices > 2ms = risk of frame drops
  2. RenderThread draw > 2ms = complex display lists or heavy overdraw
  3. Choreographer#doFrame duration > 4ms = dropped frame
  4. Recomposition trace sections during animation frames = unnecessary work

Tools

ToolPurpose
Layout InspectorLive recomposition counts per composable
Compose Stability Analyzer (IDE plugin)Gutter icons for skippability
compose-report-to-html (Gradle plugin)Navigable HTML reports
PerfettoPer-composable timing, GPU analysis
MacrobenchmarkEnd-to-end frame timing, automated
MicrobenchmarkHot function cost measurement

19. Master Checklist

Infrastructure (Do This First)

  • Build and ship in Release mode with R8 (proguard-android-optimize.txt)
  • Generate and ship Baseline Profiles covering critical user journeys
  • Upgrade to Compose BOM 2025.12.00+ for pausable composition
  • Request high refresh rate via Surface.setFrameRate() or preferredDisplayModeId
  • Enable Strong Skipping (default in Kotlin 2.0.20+)

Composition Phase (Target: <1ms)

  • Defer all fast-changing state reads to latest possible phase (draw > layout > composition)
  • Use lambda modifiers everywhere: Modifier.offset { }, Modifier.graphicsLayer { }, Modifier.drawBehind { }
  • Use @Immutable/@Stable on data classes
  • Use ImmutableList/ImmutableSet from kotlinx-collections-immutable
  • Use derivedStateOf for scroll-dependent / threshold-dependent UI
  • Offload computation to Dispatchers.Default
  • Keep composables small and focused to narrow recomposition scopes
  • Use movableContentOf when composables move between layout positions
  • Avoid SubcomposeLayout and BoxWithConstraints in hot paths

Layout Phase (Target: <1ms)

  • Avoid intrinsic measurements in deep trees — use explicit sizes
  • Use standard Layout over SubcomposeLayout where possible
  • Flatten layout hierarchies with custom Layout composables
  • Use lambda offset {} to defer state reads to layout phase
  • Separate measurement from placement for scroll-dependent positioning

Draw Phase (Target: <1ms)

  • Use graphicsLayer {} lambda for ALL animated alpha, rotation, scale, translation
  • Use drawWithCache to cache Path, Brush, Shader objects
  • Use CompositingStrategy.ModulateAlpha for non-overlapping alpha
  • Pre-compute text layouts with TextMeasurer
  • Use Canvas.drawText() to bypass Compose layout for static text
  • Set TextMotion.Animated for text with animated transforms

Lazy Lists (Target: 0% jank)

  • Always provide stable key parameters
  • Use contentType for heterogeneous lists
  • Configure LazyLayoutCacheWindow for prefetch
  • Set nestedPrefetchItemCount for nested lazy lists
  • Use AsyncImage (not SubcomposeAsyncImage) inside lists
  • Manually remember lambdas in LazyListScope (not auto-memoized)

Animation

  • Use Animatable + graphicsLayer{} for gesture-driven animations
  • Use animate*AsState + graphicsLayer{} for state transitions
  • Use withFrameNanos for custom physics loops
  • Consider SkippingFrameClock for non-critical animations to save power
  • Use ScaleToBounds (not RemeasureToBounds) in shared transitions

Memory

  • Use mutableIntStateOf, mutableFloatStateOf to avoid autoboxing
  • Use remember for all object creation in composables
  • Use value classes for wrapper types
  • Avoid allocations in draw lambdas — use drawWithCache
  • Use Dispatchers.Main.immediate where appropriate

Modifier System

  • Migrate Modifier.composed to Modifier.Node
  • Order modifiers: Layout -> Appearance -> Interactions
  • Hoist static modifier chains outside recomposition scope
  • Use shouldAutoInvalidate = false + manual invalidateDraw()/invalidateMeasurement() in custom nodes

Overdraw & Layers

  • Remove redundant backgrounds in nested composables
  • Minimize transparency and alpha blending
  • Use clip = true to restrict drawing boundaries
  • Profile with “Debug GPU Overdraw” developer option

Profiling (Continuous)

  • Profile on physical device with profileable build
  • Target frameDurationCpuMs < 4.0, frameOverrunMs < 0
  • Use Macrobenchmark FrameTimingMetric for automated regression detection
  • Use Perfetto traces to identify composables > 1ms
  • Use Layout Inspector to spot unexpected recomposition counts
  • Use Compose compiler reports to audit stability

Priority Order for Maximum Impact

  1. graphicsLayer {} lambda for all animations — eliminates recomposition during animation (single biggest win)
  2. Baseline Profiles + R8 full mode — 30%+ free performance
  3. Stable keys on all lazy lists — eliminates unnecessary recomposition of list items
  4. derivedStateOf for scroll-dependent UI — reduces recomposition frequency by orders of magnitude
  5. ImmutableList/ImmutableSet — makes composables skippable
  6. Pausable composition (Compose 1.9+) — splits work across frames automatically
  7. SubcomposeLayout removal — 8%+ P99 improvement
  8. Modifier.Node migration — 80% improvement per migrated modifier
  9. drawWithCache for draw-phase objects — eliminates per-frame allocation
  10. Background thread offloading — keeps main thread under 2ms