Skip to content
All Skills

Avoiding Subcomposition Pitfalls

Use this skill when a Compose tree uses SubcomposeLayout, BoxWithConstraints, or Scaffold and the developer reports extra measure passes, slow first frame, or layout passes running content composition repeatedly. Covers why SubcomposeLayout composes its slots during the measure phase, why BoxWithConstraints forces a subcomposition for every new Constraints value, why nesting Scaffold or BoxWithConstraints multiplies the cost, when a custom Layout or Modifier.layout { } replaces SubcomposeLayout, and how to use SubcomposeLayoutState's slot reuse policy and precompose APIs when SubcomposeLayout is genuinely required. Use when the developer mentions BoxWithConstraints, SubcomposeLayout, Scaffold, "extra measure pass", "double measurement", "first frame slow", "subcompose", or notices that wrapping content in BoxWithConstraints regresses scroll perf inside a LazyColumn.

Mobile App Development|v1|Updated 7/14/2026|GitHub source
MCP get_skill({ skillId: "avoiding-subcomposition-pitfalls-23cba40f" })

Use this skill with your agent

Create a free account and connect via MCP

Get Started Free
# Avoiding Subcomposition Pitfalls — Keep Composition Out of the Measure Pass

`SubcomposeLayout` runs its content's composition **during the measure pass**, not during the parent's composition pass. That trades extra layout cost for the ability to use measured constraints inside the children's composition. `BoxWithConstraints`, `material3.Scaffold`, and the lazy layouts are all built on top of it, so the cost is invisible until it nests or lands inside a hot path. This skill teaches Claude how to detect the misuse, how to replace `SubcomposeLayout` with a cheaper primitive when its power is not needed, and how to keep it efficient when it is.

## When to use this skill

- The developer wraps content in `BoxWithConstraints` purely to read `maxWidth` / `maxHeight` and pick between a few composables, and the screen feels heavy at first frame or on configuration change (rotation, IME show).
- A `BoxWithConstraints` or `Scaffold` is nested inside another `BoxWithConstraints` or `Scaffold`, multiplying subcomposition cost.
- A `BoxWithConstraints` sits inside a `LazyColumn`/`LazyRow` item and the developer reports scroll jank that did not exist before the wrap was added.
- The developer manually authors a `SubcomposeLayout` block but only uses constraints to compute final positions, never to drive child composition.
- The Layout Inspector or a Perfetto trace shows `Compose:applyChanges` or measure-phase composition counts that scale with parent constraint changes (rotation, drag-to-resize, animated container size).
- A custom `SubcomposeLayout`'s `measurePolicy` block allocates a fresh composable lambda inside `subcompose(slotId) { … }` on every measurement. AndroidX maintains an internal lint check named `ComposableLambdaInMeasurePolicy` (in `compose/lint/internal-lint-checks/`) that flags exactly this pattern on its own codebase; published APIs like `BoxWithConstraints`, `material.Scaffold`, and `material3.TabRow` suppress that check because the trade-off is fundamental to their public contract. App-side code that reproduces the pattern pays the same cost without the suppression rationale.

## When NOT to use this skill

- The developer needs to compose a child whose content depends on another child's measured size — that is the canonical `SubcomposeLayout` use case and there is no cheaper primitive. Keep `SubcomposeLayout`; tune it (see Pattern: Tuning a justified SubcomposeLayout).
- The slow path is inside a `LazyColumn` item but the cause is unstable parameters, not subcomposition. Diagnose with `../../stability/diagnosing-compose-stability/SKILL.md` first.
- The state read driving repeated measurement is an animation value rather than a structural constraint. That is `../deferring-state-reads/SKILL.md`.
- The user wants to optimize lazy list prefetch — that is `../../lists/configuring-lazy-prefetch/SKILL.md`.

## Prerequisites

- Familiarity with Compose's three phases (Composition, Layout, Draw). If unsure, read `../deferring-state-reads/references/three-phases.md` first.
- A **release** build with R8 for any final measurement. `../../measurement/testing-compose-in-release-mode/SKILL.md` covers why debug builds lie.
- Layout Inspector or Perfetto trace tooling. `../../measurement/tracing-recompositions-at-runtime/SKILL.md` covers wiring `androidx.compose.runtime:runtime-tracing` so that measure-phase work shows up in system traces.
- A modern Compose UI version that exposes the `SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)` factory and pausable subcomposition on the lazy-list path. Both ship in the current `androidx.compose.ui:ui` / `androidx.compose.foundation:foundation` artifacts; check the release notes at https://developer.android.com/jetpack/androidx/releases/compose-ui and https://developer.android.com/jetpack/androidx/releases/compose-foundation if pinning to an older release.

## Workflow

- [ ] **1. Identify every `SubcomposeLayout` user in the hot path.** Grep the affected screen for `BoxWithConstraints`, `Scaffold`, `SubcomposeLayout`, and any custom `@Composable` that delegates to one. The `material3.Scaffold` and `foundation.layout.BoxWithConstraints` are both implemented with `SubcomposeLayout`; their cost is identical.

- [ ] **2. For each user, decide whether the constraints are actually consumed during composition.** A `BoxWithConstraints` that reads `maxWidth` only to pass it to a child as a parameter is wasting the subcomposition machinery. A `BoxWithConstraints` that picks between two completely different `@Composable` trees based on `maxWidth < 600.dp` is using it correctly.

- [ ] **3. Replace unjustified `BoxWithConstraints` with a cheaper primitive.** If the consumer only needs the final size in pixels (e.g. to compute an offset, an alpha, a draw command), use `Modifier.layout { measurable, constraints -> … }` or `Modifier.onSizeChanged { … }` instead. Both run during the layout phase without spinning up a new composition.

- [ ] **4. Avoid nesting subcomposing layouts.** Two `Scaffold`s nested, or a `BoxWithConstraints` inside a `Scaffold` slot that itself contains another `BoxWithConstraints`, multiplies measure-phase composition. Hoist the outer one to the screen root and use ordinary `Box` / `Column` / `Row` for the inner branches.

- [ ] **5. Never wrap a `LazyColumn`/`LazyRow` item in `BoxWithConstraints`.** Each item subcomposes during measurement. If the item needs the available width, pass it down once from the parent (the `LazyColumn` already knows its constraints) or use `Modifier.fillMaxWidth().onSizeChanged { … }` for one-time sizing.

- [ ] **6. When `SubcomposeLayout` is justified, configure `SubcomposeLayoutState(SubcomposeSlotReusePolicy(n))`.** The state object retains up to `n` previously-used slots so the next subcomposition reuses their slot table and node subtree instead of paying for a fresh composition. The factory `SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)` ships in `androidx.compose.ui.layout`.

- [ ] **7. For predictable upcoming slots, call `state.precompose(slotId, content)` on a previous frame.** It returns a `PrecomposedSlotHandle`; the next `subcompose(slotId, …)` during measurement skips the composition step. This is exactly how the lazy layouts spread item composition across frames.

- [ ] **8. Verify in a Perfetto system trace.** Add `androidx.compose.runtime:runtime-tracing` and confirm that `Compose:recompose` and `Compose:applyChanges` no longer fire during scroll or resize ticks at the rate of the parent's constraint changes. The measured screen should show subcomposition only on actual structural change (e.g. rotation), not on every frame.

## Patterns

### Pattern: `BoxWithConstraints` used to read size — replace with `Modifier.onSizeChanged`

```kotlin
// WRONG
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
    BoxWithConstraints {
        val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
        Image(
            painter = painterResource(R.drawable.hero),
            modifier = Modifier
                .fillMaxWidth()
                .height(220.dp)
                .graphicsLayer {
                    translationX = scrollOffset() * widthPx * 0.2f
                },
            contentDescription = null,
        )
    }
}
// WRONG because: BoxWithConstraints subcomposes its content during the measure phase. Each
// new constraint (rotation, IME show, animated parent size) re-runs that subcomposition just
// to read maxWidth, when a Modifier.onSizeChanged would have given the same value with no
// extra composition.
```

```kotlin
// RIGHT
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
    var widthPx by remember { mutableStateOf(0f) }
    Image(
        painter = painterResource(R.drawable.hero),
        modifier = Modifier
            .fillMaxWidth()
            .height(220.dp)
            .onSizeChanged { widthPx = it.width.toFloat() }
            .graphicsLayer {
                translationX = scrollOffset() * widthPx * 0.2f
            },
        contentDescription = null,
    )
}
```

`onSizeChanged` runs in the layout phase (no subcomposition); the underlying `onRemeasured` is invoked on every layout pass and forwards to the user callback when the measured size differs from the previous size. The `graphicsLayer` block runs in Draw with the latest `widthPx` and the latest scroll offset.

### Pattern: `BoxWithConstraints` to pick a layout — keep it but hoist it

```kotlin
// WRONG
@Composable
fun ProductCard(product: Product) {
    BoxWithConstraints {
        if (maxWidth < 360.dp) {
            CompactProductCard(product)
        } else {
            WideProductCard(product)
        }
    }
}

@Composable
fun ProductGrid(products: List<Product>) {
    LazyVerticalGrid(columns = GridCells.Fixed(2)) {
        items(products) { ProductCard(it) }
    }
}
// WRONG because: every grid cell wraps its content in BoxWithConstraints. Each cell pays
// for a subcomposition during the grid's measure pass. With 200 cells visible across a long
// scroll session, that is 200 redundant subcompositions on every measurement.
```

```kotlin
// RIGHT
@Composable
fun ProductGrid(products: List<Product>) {
    BoxWithConstraints {
        val isCompact = maxWidth / 2 < 360.dp
        LazyVerticalGrid(columns = GridCells.Fixed(2)) {
            items(products) { product ->
                if (isCompact) CompactProductCard(product) else WideProductCard(product)
            }
        }
    }
}
```

The single `BoxWithConstraints` at the grid root subcomposes once per parent constraint change. The decision flows into every item as a plain `Boolean`, which is stable and skippable.

### Pattern: Nested `Scaffold` — collapse to one

```kotlin
// WRONG
@Composable
fun ProfileScreen(...) {
    Scaffold(topBar = { TopBar(...) }) { outer ->
        Scaffold(
            modifier = Modifier.padding(outer),
            bottomBar = { ProfileTabBar(...) },
        ) { inner ->
            ProfileContent(modifier = Modifier.padding(inner))
        }
    }
}
// WRONG because: each Scaffold is a SubcomposeLayout with its own measure-phase composition.
// Nesting them doubles that cost on every frame whose constraints change (IME show, rotation,
// nav bar inset change).
```

```kotlin
// RIGHT
@Composable
fun ProfileScreen(...) {
    Scaffold(
        topBar = { TopBar(...) },
        bottomBar = { ProfileTabBar(...) },
    ) { padding ->
        ProfileContent(modifier = Modifier.padding(padding))
    }
}
```

`Scaffold` already supports a top bar, bottom bar, snackbar host, and FAB in a single subcomposition. Use the slots provided rather than nesting another `Scaffold`.

### Pattern: Tuning a justified `SubcomposeLayout`

When a screen genuinely needs measure-time access to constraints (a tab strip whose tabs size by their own content while sharing the row's max width, a lookahead-driven shared element container, a custom popover that aligns to a measured anchor), keep `SubcomposeLayout` but tune the state.

```kotlin
@Composable
fun BadgeRow(badges: List<Badge>) {
    val slotState = remember {
        SubcomposeLayoutState(SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse = 8))
    }
    SubcomposeLayout(state = slotState) { constraints ->
        val measurables = subcompose(slotId = "badges") {
            badges.fastForEach { Badge(it) }
        }
        val placeables = measurables.map { it.measure(constraints) }
        val width = placeables.sumOf { it.width }
        val height = placeables.maxOf { it.height }
        layout(width, height) {
            var x = 0
            placeables.fastForEach { p ->
                p.place(x, 0)
                x += p.width
            }
        }
    }
}
```

`SubcomposeSlotReusePolicy(8)` lets the layout retain up to eight previously-composed slots. When a slot id reappears (e.g. a badge enters the screen again after exiting), the layout reuses the existing slot table instead of rerunning composition. The lazy layouts use a similar mechanism with their own policy implementation.

For **predictable upcoming slots**, call `state.precompose(slotId, content)` on an earlier frame and dispose the returned `PrecomposedSlotHandle` if the slot is not needed after all. The next `subcompose(slotId, …)` during measurement reuses the precomposed content with no composition step.

### Pattern: `LazyListScope.item { }` containing `BoxWithConstraints`

```kotlin
// WRONG
LazyColumn {
    items(rows) { row ->
        BoxWithConstraints {
            val isWide = maxWidth > 600.dp
            RowContent(row, isWide)
        }
    }
}
// WRONG because: each row item subcomposes during the LazyColumn's measure pass. Scroll the
// list and every newly-visible item pays for a fresh subcomposition. On a 60Hz feed this is
// the difference between smooth and dropped frames.
```

```kotlin
// RIGHT
@Composable
fun RowFeed(rows: List<Row>) {
    BoxWithConstraints {
        val isWide = maxWidth > 600.dp
        LazyColumn {
            items(rows, key = { it.id }) { row -> RowContent(row, isWide) }
        }
    }
}
```

The single `BoxWithConstraints` at the feed root subcomposes only when the feed itself is remeasured. The per-row item is a plain `RowContent` call with a stable `Boolean` parameter, which strong skipping handles.

## Mandatory rules

- **MUST NOT** use `BoxWithConstraints` only to read `maxWidth`/`maxHeight` for a `Modifier`-level effect. Use `Modifier.onSizeChanged { … }` or `Modifier.layout { … }` instead.
- **MUST NOT** place a `BoxWithConstraints` inside a `LazyColumn`/`LazyRow`/`LazyVerticalGrid` item. Hoist the constraint read to the lazy layout's parent and pass the resolved value down.
- **MUST NOT** nest `Scaffold` inside another `Scaffold`. Use the slots (`topBar`, `bottomBar`, `snackbarHost`, `floatingActionButton`) the outer `Scaffold` already exposes.
- **MUST** keep a `SubcomposeLayout` when the children's composition genuinely depends on a measured value (parent constraints, sibling size). The point is to use the right tool, not to ban `SubcomposeLayout`.
- **MUST** pass a `SubcomposeSlotReusePolicy` to `SubcomposeLayoutState` whenever slots come and go. The default no-op policy disposes slots eagerly and pays full composition cost on every reappearance.
- **PREFERRED:** for predictable upcoming slots, call `state.precompose(slotId, content)` on a previous frame and let the measure-pass `subcompose` reuse the result.
- **PREFERRED:** measure once with `Modifier.onSizeChanged { … }` over reading `maxWidth` in a `BoxWithConstraints` body when the value only feeds a `graphicsLayer { }`, `drawBehind { }`, or other Layout/Draw-phase consumer.

## Verification

- [ ] A Perfetto system trace recorded with `androidx.compose.runtime:runtime-tracing` shows that `Compose:recompose` and `Compose:applyChanges` no longer fire on every measurement of the affected subtree. They should fire only on real structural change (rotation, navigation, list item insert).
- [ ] Layout Inspector's recomposition count for any subtree wrapped by the prior `BoxWithConstraints` does not increment per parent constraint change.
- [ ] In any retained custom `SubcomposeLayout`, the `subcompose(slotId) { … }` content lambda is hoisted into a `remember`-stable reference (or is cheap and intentional, with a one-line comment explaining why a fresh lambda per measurement is safe here). This is the pattern AndroidX's internal `ComposableLambdaInMeasurePolicy` lint enforces on the AndroidX codebase.
- [ ] A grep over the migrated file finds zero `BoxWithConstraints` blocks inside `LazyColumn`/`LazyRow`/`LazyVerticalGrid` items.
- [ ] No `Scaffold` is nested inside another `Scaffold` in the migrated file.
- [ ] For every retained `SubcomposeLayout`, a `SubcomposeSlotReusePolicy` is passed to `SubcomposeLayoutState`, OR the comment explicitly notes that all slots are permanent and reuse is unnecessary.

## References

- Android Developers — Custom layouts: https://developer.android.com/develop/ui/compose/layouts/custom
- Android Developers — Phases of Jetpack Compose: https://developer.android.com/develop/ui/compose/phases
- Android Developers — `SubcomposeLayout` reference: https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)
- Compose UI source — `SubcomposeLayout.kt`: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
- Compose foundation source — `BoxWithConstraints.kt`: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/BoxWithConstraints.kt
- Material3 source — `Scaffold.kt` (uses `SubcomposeLayout` for the chrome layout): https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt
- Chris Banes — "Composable metrics" (subcompose cost in Perfetto traces): https://chrisbanes.me/posts/composable-metrics/
- skydoves — Jetpack Compose Mechanism (composition vs subcomposition): https://speakerdeck.com/skydoves/jetpack-compose-mechanism
- `../deferring-state-reads/references/three-phases.md` — three-phase model background that motivates "do not run composition during measure".
- `../../lists/configuring-lazy-prefetch/SKILL.md` — the lazy layouts use `precompose` and pausable subcomposition; the same APIs are available to custom `SubcomposeLayout` users.
#android#jetpack-compose#performance#kotlin#compose#state#preservationjetpack-compose

Related Skills

More skills in Mobile App Development

Android Design Guidelines

Material Design 3 and Android platform guidelines. Use when building Android apps with Jetpack Compose or XML layouts, implementing Material You, navigation, or accessibility. Triggers on tasks involving Android UI, Compose components, dynamic color, or Material Design compliance.

#work-life#productivityMIT

Android Java Skill

Android Java development with MVVM, ViewBinding, and Espresso testing

#github#externalMIT

Android Kotlin

Android Kotlin development with Coroutines, Jetpack Compose, Hilt, and MockK testing

#claude-bootstrap#bootstrapMIT

Appium Skill

Generates production-grade Appium mobile automation scripts for Android and iOS in Java, Python, or JavaScript. Supports real device and emulator testing locally and on TestMu AI cloud with 100+ real devices. Use when the user asks to automate mobile apps, test on Android/iOS, write Appium tests, or mentions "Appium", "mobile testing", "real device", "app automation". Triggers on: "Appium", "mobile test", "Android test", "iOS test", "real device", "app automation", "UiAutomator", "XCUITest driver", "TestMu", "LambdaTest".

#testing#automationMIT

Apple Appstore Reviewer

Serves as a reviewer of the codebase with instructions on looking for Apple App Store optimizations or rejection reasons.

#github-copilot#appMIT

App Rejection Recovery

When the user's app or update was rejected by Apple App Review or Google Play Review and they need to diagnose why, fix it, and resubmit fast. Use when the user mentions "app rejected", "App Review rejection", "guideline violation", "Apple rejected my app", "Google Play rejected", "Play policy violation", "Resolution Center", "metadata rejection", "binary rejection", "guideline 2.1", "guideline 4.3", "guideline 5.1.1", "Sign in with Apple required", "Apple ID rejection", "Play Store suspension", "appeal", "I need to respond to App Review", or "expedited review". For pre-submission listing health, see aso-audit. For metadata-only fixes, see metadata-optimization.

#work-life#productivityMIT

Explore Other Categories

Skills from other categories with shared topics

Auditing Compose Performance

Use this skill to run an end-to-end Jetpack Compose performance audit when the symptom is broad ("the app feels sluggish", "scroll is rough everywhere", "we're starting a perf sprint", "what should we fix first?"). Orchestrates the four-phase Measure → Diagnose → Fix → Verify loop by sequencing the 25 focused skills (release-mode setup, R8, Baseline Profiles, Compose Compiler reports, stability inference, Layout Inspector, `@TraceRecomposition`, stabilization, strong skipping, phase-deferral, derivedStateOf, lazy layouts, lazy prefetch, Modifier.Node, modifier ordering, flow collection, effects, CI gates, hot-reload) and produces a written audit report with Before/After Macrobenchmark numbers. Use when the developer wants a perf sprint kickoff, a pre-release perf gate, onboarding to a perf-troubled codebase, or a written deliverable. Use when the user mentions "audit", "perf review", "perf sprint", "where do I start", or has no specific symptom yet.

Software Engineering#android#jetpack-compose

Configuring R8 For Compose

Use this skill to configure R8 correctly for a Jetpack Compose application — full mode by default, `proguard-android-optimize.txt`, resource shrinking on, and minimal keep rules because Compose ships consumer ProGuard rules. Covers AGP 8.0+ R8 full mode default, R8's Compose-aware optimizations (lambda grouping, `sourceInformation()` stripping, composable arg constant-folding, `ComposerImpl` devirtualization), legitimate keep needs (`@Serializable`, Hilt entry points, reflective `Saver`s), and the AGP 8.x missing-rule reporter / R8 retrace. Cited gain is roughly 75 percent startup and 60 percent frame-render improvement debug-to-release. Use when setting up a new Compose app, when a PR adds an over-broad keep like `-keep class androidx.compose.** { *; }`, when a release build crashes after enabling minification, when APK size needs reduction, or when first enabling minification.

Software Engineering#android#jetpack-compose

Stabilizing Compose Types

Use this skill to fix unstable Jetpack Compose types once a stability diagnosis has identified them. Covers the three-tier strategy — make the type truly stable with val plus immutable fields, mark with @Immutable or @Stable when the source is owned, and use stabilityConfigurationFiles for third-party or Java types. Explains the compiler-level difference between @Immutable and @Stable (static expression promotion), kotlinx.collections.immutable for List/Set/Map parameters, and the StableHolder wrapper escape hatch. Use when the developer asks how to stabilize a User class, a List parameter, java.time.LocalDateTime, a Flow parameter, or when the compiler report shows unstable params and the developer wants the fix. The diagnostic step lives in a sibling skill.

Software Engineering#android#jetpack-compose