Skip to content
Shreyas Patil's Blog

Inside SubcomposeLayout: Jetpack Compose’s Most Misunderstood API

Cover image for Inside SubcomposeLayout: Jetpack Compose’s Most Misunderstood API

Hey Composers 👋, if you’ve built a complex UI in Jetpack Compose, you’ve probably reached for BoxWithConstraints. And by doing so, you’ve probably paid a performance tax you didn’t fully understand.

SubcomposeLayout (the engine powering BoxWithConstraints, LazyColumn, and complex custom layouts) is a blunt instrument that breaks the fundamental rules of the Compose pipeline.

There’s a popular piece of advice floating around: SubcomposeLayout is expensive, avoid it.” But why is it expensive? What exactly happens under the hood when you subcompose? In this post, we’ll look directly at the AOSP source code to understand what subcomposition really does, why it stalls the layout pipeline, and where the real cost comes from.

🚨
TL;DR / The Decision Matrix:
Before you write a SubcomposeLayout, ask yourself:
  • Need child A's size to measure child B? → Use a standard Layout or LookaheadScope.
  • Need parent constraints to change the size or position logic? → Use a standard custom Layout.
  • Need parent constraints to dictate if a Composable is even emitted? → Use SubcomposeLayout.

A quick refresher: composition then layout

In a standard Compose layout, there’s a clean separation of phases. First, composition fully constructs the tree of LayoutNodes by running your @Composable functions. Then, layout (measure + place) figures out the size and position of every node. Finally, draw paints them.

flowchart TD
    A([Composition]) -->|⚠️ SubcomposeLayout violates this| B([Layout: Measure + Place])
    B --> C([Draw])
AspectRegular LayoutSubcomposeLayout
When children composeBefore measureDuring measure
Compositions involvedShares parentOne per slotId
Cost paid in measure passMeasurement onlyComposition + measurement
Best forMost layoutsConstraint-dependent content

The key rule here is: composition happens before layout. You can’t decide which composables to emit based on a measured size, because by the time you’re measuring, composition is already done.

But what if you genuinely need to? What if a child’s content depends on the constraints handed down by the parent? That’s exactly the problem SubcomposeLayout was built to solve.


The “What”: composing during the layout pass

SubcomposeLayout does something that sounds almost contradictory: it lets you run composition inside the measure or layout (placement) pass.

Think of a standard Layout as a restaurant kitchen with a prix fixe menu. You order everything upfront (Composition), and then the chef plates it all at once (Layout). SubcomposeLayout is like stopping the entire kitchen mid-plating, walking back to the stove, and cooking a brand new side dish because you realized the plate had extra room. It stalls the pipeline.

Instead of composing all children up front, you get a subcompose function that you call during measurement, once you already know the incoming constraints.

Here’s the canonical example everyone meets first, BoxWithConstraints, which is itself built on SubcomposeLayout:

@Composable
fun ResponsiveContent() {
    BoxWithConstraints {
        // We now know the constraints BEFORE deciding what to compose.
        if (maxWidth < 600.dp) {
            CompactLayout()
        } else {
            ExpandedLayout()
        }
    }
}ResponsiveContent.kt

The mechanism here is that CompactLayout() and ExpandedLayout() are not composed until the box has been measured. The decision is deferred to the layout phase.

The lower-level API looks like this:

@Composable
fun MySubcomposeLayout() {
    SubcomposeLayout { constraints ->
        // `subcompose` runs composition for the given slotId NOW,
        // during the measure pass, and returns measurables.
        val measurables = subcompose(slotId = "content") {
            // 🚨 CRITICAL: Check for infinite constraints before passing them down!
            // If this layout is inside a LazyColumn, constraints.maxWidth will be Infinity.
            // If you return an infinite dimension from the layout() block, or if a child
            // attempts to fill unbounded width, the framework will crash.
            val widthPx = if (constraints.hasBoundedWidth) constraints.maxWidth else 0
            val widthDp = with(LocalDensity.current) { widthPx.toDp() }
            MyContent(availableWidth = widthDp)
        }

        val placeables = measurables.map { it.measure(constraints) }

        // Protect against exceeding constraints if we have multiple children
        val width = (placeables.maxOfOrNull { it.width } ?: 0).coerceAtMost(constraints.maxWidth)
        val height = placeables.sumOf { it.height }.coerceAtMost(constraints.maxHeight)

        layout(width, height) {
            var y = 0
            placeables.forEach { placeable ->
                // Always use placeRelative to respect RTL!
                // Using x = 10 to demonstrate RTL placement (mirrored in RTL mode)
                placeable.placeRelative(10, y)
                y += placeable.height
            }
        }
    }
}MySubcomposeLayout.kt
💡
Mental model: A regular Layout receives already-composed children as measurables. A SubcomposeLayout receives a lambda and decides when (and whether) to turn it into measurables by calling subcompose during measurement.

The “Right” Way: A Real-World Use Case

If SubcomposeLayout is so dangerous, when should you actually use it?

The golden rule is: Use it when the emission of nodes depends on the measurement of previous nodes.

Let’s look at a real-world example. Imagine you are building a DynamicChipRow. You have a list of 20 tags. You want to display as many as will fit horizontally on one line. If they don’t all fit, you want to show a custom +X more chip at the very end.

If you try to build this with a standard Layout, you hit a wall. In a standard Layout, you have to compose all 20 chips upfront just to measure them. Worse, you haven’t composed the +X more chip because you didn’t know you needed it yet!

Here is where SubcomposeLayout shines. You can measure chips one by one, and dynamically subcompose the overflow chip only when you run out of space:

@Composable
fun DynamicChipRow(
    items: List<String>,
    itemContent: @Composable (String) -> Unit,
    overflowContent: @Composable (Int) -> Unit
) {
    SubcomposeLayout { constraints ->
        var xPosition = 0
        val placeables = mutableListOf<Placeable>()
        var itemsPlaced = 0

        // 1. We subcompose and measure items one by one in a loop
        for (i in items.indices) {
            val measurables = subcompose("item_$i") { itemContent(items[i]) }
            val placeable = measurables.first().measure(constraints)

            if (xPosition + placeable.width > constraints.maxWidth) {
                // 2. OUT OF SPACE! We stop composing regular items.
                break
            }

            placeables.add(placeable)
            xPosition += placeable.width
            itemsPlaced++
        }

        // 3. Do we have leftover items? Subcompose the overflow indicator!
        val remaining = items.size - itemsPlaced
        if (remaining > 0) {
            // We ONLY compose the overflow chip if we actually need it
            val overflowMeasurables = subcompose("overflow") { overflowContent(remaining) }
            val overflowPlaceable = overflowMeasurables.first().measure(constraints)

            // (In a production app, you'd calculate if the overflow chip itself fits here)
            placeables.add(overflowPlaceable)
        }

        // 4. Place everything
        layout(constraints.maxWidth, placeables.maxOfOrNull { it.height } ?: 0) {
            var x = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x, 0)
                x += placeable.width
            }
        }
    }
}DynamicChipRow.kt

Notice the power here? If only 3 chips fit on the screen, we never paid the composition cost for the remaining 17 chips. We stopped the kitchen mid-plating, realized we needed an overflow chip, went back to the stove to compose it, and then finished the layout. This is what the API was built for.

Here is how you would use this component in your code:

DynamicChipRow(
    items = listOf("Cheese", "Paneer", "Milk", "Curd", "Ice cream", "Kulfi", "Rose Milk", "Butter Milk", "Milkshake"),
    itemContent = { Button({}) { Text(it) } },
    overflowContent = { TextButton({}) { Text("+$it") } }
)

And here is how it renders on the device:

Dynamic Chip Row Preview

Looking at this example, we pass in 9 items. But as you can see, maybe only “Cheese”, “Paneer”, “Milk”, and “Curd” actually fit horizontally on the screen. Because of SubcomposeLayout, Compose never even executes the Button composition or measurement for “Ice cream”, “Kulfi”, and the rest of the list. By entirely skipping the composition and measurement of those unseen items, we avoid wasting CPU and memory resources, directly improving frame rendering performance.


How it works: the core machinery

By looking at the SubcomposeLayout source, we can see that the real workhorse isn’t the public composable at all, it’s an internal state holder called LayoutNodeSubcompositionsState.

The SubcomposeLayout composable mostly wires up three things:

  1. A SubcomposeLayoutState, which owns the LayoutNodeSubcompositionsState.
  2. The root LayoutNode that will host all the subcomposed children.
  3. A measure policy that gives you the subcompose { } function inside the SubcomposeMeasureScope.
// Simplified shape of the internal state holder
internal class LayoutNodeSubcompositionsState(
    private val root: LayoutNode,
    private var slotReusePolicy: SubcomposeSlotReusePolicy,
) {
    // Even the bookkeeping is allocation-optimized with ScatterMap
    private val nodeToNodeState = mutableScatterMapOf<LayoutNode, NodeState>()
    private val slotIdToNode = mutableScatterMapOf<Any?, LayoutNode>()

    // Each slotId gets its OWN child composition.
    fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable> {
        // ...
    }
}

Two things in that snippet are the whole story of why subcomposition costs what it does:

Each slot is its own little composition

When you call subcompose(slotId, content), the state holder:

  1. Finds (or creates) a LayoutNode for that slotId.
  2. Finds (or creates) a child Composition for that node, parented to the enclosing composition context.
  3. Calls composition.setContent(content) to actually run your composable.
  4. Returns the node’s children as a List<Measurable> so you can measure them.
// Conceptually, inside subcompose(...)
private fun subcompose(node: LayoutNode, content: @Composable () -> Unit) {
    val nodeState = nodeToNodeState.getOrPut(node) { NodeState(...) }

    nodeState.content = content
    subcomposeInto(node) {
        // Runs composition for just this slot's content,
        // creating a child composition under the parent's context.
        // It uses createPausableSubcomposition or createSubcomposition based on state.
        composition = reuseOrCreateComposition(existing, node, ...)

        // Wrapping in withoutReadObservation ensures that measure-time
        // composition doesn't register phantom snapshot-read dependencies on the parent.
        Snapshot.withoutReadObservation {
            composition.setContent(content)
        }
    }
}

Subcomposition is real composition. It runs the composer, allocates slot tables, processes remembers, registers RememberObservers and SideEffects, the full machinery. It’s not a cheap “layout-only” shortcut. The cost is the cost of composing those composables, just shifted into the measure pass.

If you’re wondering how parent recomposition propagates into child subcompositions, SubcomposeLayout achieves this by executing a SideEffect { state.forceRecomposeChildren() } in its public composable.

flowchart TD
    A([SubcomposeLayout measure pass]) --> B{For each slotId}
    B --> C{Already composed & valid?}
    C -->|Yes| D[Active Reuse: same slotId already active this pass]
    C -->|No| E[Find or create LayoutNode]
    E --> F[Find or create child Composition]
    F --> G[composition.setContent]
    G --> H[Run composer: remember, effects, emit nodes]
    H --> I[Return children as Measurables]
    D --> J[measure + place]
    I --> J

The real cost, broken down

So where does the “SubcomposeLayout is expensive” reputation actually come from? Here is what is actually happening in the frame budget.

1. Composition runs during measurement

In a normal layout, composition is already finished before measuring begins, so the measure pass is pure math on already-built nodes. With SubcomposeLayout, the first time a slot is measured you pay for composition + measurement together, on the layout thread, inside the frame’s measure budget.

If your subcomposed content is heavy (lots of composables, expensive remember calculations, etc.), that work now competes with the time you have to measure and draw the current frame.

2. The per-frame subcompose tax

The real devil of subcomposition isn’t just the one-time layout overhead: it’s the per-frame tax.

What happens if you animate a parent’s size and read those continuously-changing constraints inside your subcompose block? It forces subcomposition every single frame. Because the constraints change, the content lambda is a new closure instance each measure pass. You aren’t just running math calculations; you are re-running composition — the composer, every remember, every invalidation check — on the reused node, 60 times a second.

BoxWithConstraints vs Modifier.layout

BoxWithConstraints isn’t the villain — reading a continuously-changing constraint inside it is. A breakpoint boolean (maxWidth < 600.dp) rarely flips, so it’s cheap. But a pixel-precise animating read causes a 60×/s composition tax.

Watch this drop frames on your own device:

val padding by animateDpAsState(targetValue = if (toggled) 32.dp else 0.dp)
BoxWithConstraints(Modifier.padding(padding)) {
    // Recomposes 60x a second during animation!
    MyHeavyContent(constraints.maxWidth)
}

The fix is ripping out BoxWithConstraints and using Modifier.layout to mathematically calculate the offset during the placement phase, completely bypassing the need to subcompose. This is the #1 way developers shoot themselves in the foot, causing massive UI jank.

3. Extra compositions = extra bookkeeping

Each slot owns a separate child Composition. Compositions aren’t free; they each maintain their own slot table and lifecycle. A SubcomposeLayout with many distinct slotIds is maintaining many child compositions, which means more memory and more work to keep them in sync, dispose them, and so on.


The escape hatch: slot reuse

The Compose team knew the per-slot composition cost would hurt in scrolling scenarios like LazyColumn (whose underlying LazyLayout primitive builds on SubcomposeLayout). So SubcomposeLayout ships with a powerful optimization: slot reuse, controlled by a SubcomposeSlotReusePolicy.

Instead of disposing a composition when a slot scrolls off-screen and rebuilding it from scratch when a new one scrolls in, the state holder keeps a small pool of deactivated compositions around and reuses them.

// Reuse keeps the underlying nodes & composition, but resets state,
// so a new slot can be "re-inflated" cheaply.
// `SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse)` is a public factory that
// returns a policy retaining up to N deactivated slots in the reuse pool.
SubcomposeLayout(
    state = remember { SubcomposeLayoutState(SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse = 7)) }
) { constraints ->
    // ...
}

Under the hood, this leans on ReusableComposition:

(Note: Lazy lists also utilize precompose() to prefetch items into a precomposeMap before they are even needed for measurement, further optimizing the reuse flow.)

flowchart TD
    A(["Measure Pass: subcompose(slotId)"]) --> B{"Is slotId in precomposeMap?"}
    B -->|Yes| C["Take from precomposed, return measurables"]
    B -->|No| D{"Reuse Pool: takeNodeFromReusables()?"}
    D -->|Yes| E["Pool re-activation: Re-activate Node & ReusableComposition"]
    D -->|No| F["Create fresh LayoutNode & Composition"]
    E --> G["Run Composer"]
    F --> G
    G --> H["Return Measurables"]

This is the exact same ReusableComposition foundation that newer features like PausableComposition build upon. Lazy lists use PausableComposition to chunk composition work across frames, ensuring that pre-composing upcoming items during idle time doesn’t blow past the 16ms frame deadline. Separately, an internal OutOfFrameExecutor is used to schedule the deactivation and disposal of compositions off the frame’s critical path, keeping your scroll smooth.


Practical takeaways

After all this digging, the advice “avoid SubcomposeLayout” becomes more nuanced and actionable:

”Asking for intrinsic measurements of SubcomposeLayout layouts is not supported”

”Key was already used” in LazyColumn


Conclusion

Stop prematurely optimizing. If you are building a static screen, the subcomposition cost of a BoxWithConstraints is mostly irrelevant; a single frame-1 subcomposition cost is virtually unnoticeable. Don’t avoid it just because “Twitter said it’s slow.”

But when you move into scrolling lists or animating states, you must respect the pipeline.

The next time you build a custom layout, make a deliberate choice: do I truly need to emit nodes based on measurement, or am I about to pay for subcomposition when a plain Layout would do?

SubcomposeLayout is a chainsaw. It’s incredibly powerful when cutting down a tree (like building a responsive LazyColumn), but you don’t use a chainsaw to slice a tomato. Choose your layout tools wisely.


Let’s catch up on X or visit my site to know more about me 😎.



Next Post
Building My Dream Developer Blog with Astro, Cloudflare, and AI