← All articlesMagical Book of Compose Performance — Defer State Reads✨
26 June 2026·Originally on Medium

Person reading the Compose Performance Book (Credits Tasha Ramesh)

📚 Magical Book of Compose Performance — Defer State Reads✨

We recently gave a talk 🎤 at a couple of Android conferences about how to debug Compose Compose performance & various tools in your arsenal 🛠🧰

To make it fun we had this quirky idea where we presented the idea of this Magical Book of Compose performance being our source and after the talk, a surprising number of people came up to us asking: “Wait, is that a real book ? Where can I buy it?”

It wasn’t. It was a prop. But the reaction + the number of people who pulled me aside after each talk saying “you should write this up”; gave me an idea to cover this in an article form.

If you want to see the full talk first, it’s here on YouTube 📺

📜 A Quick Refresher: Compose’s Three Phases

Before we get into deferring state reads, you need to understand how Compose renders a frame. It does so in three phases:

  1. Composition — Compose runs your @Composable functions and builds the UI tree.
  2. Layout — Compose measures and places each node on screen.
  3. Drawing — Compose actually draws the pixels.

The key insight for performance is this ⚡:

The fewer phases Compose has to run, the better your performance.

That means if a state change only affects where something is drawn (e.g. an offset during a drag), ideally Compose should skip Composition and Layout entirely and jump straight to Drawing. Whether or not that happens depends on where you read that state.

This is where things get interesting:

If a state value is read during Composition, a change triggers recomposition — all three phases run again for that subtree. If it’s read during Layout, only the layout phase re-runs. If it’s read during Drawing, only the draw commands re-execute.

The further down the pipeline you can push a state read, the less work Compose has to do when that state changes frequently — like during a drag gesture, where state updates can arrive every single frame.

⚠️ The Problem: Reading State Too Early

Imagine a swipeable card. As the user drags it, you track the offset with a State<Float>. You might naturally write something like this:

@Composable
fun SwipeableCard(state: SwipeableCardState) {
    Card(
        modifier = Modifier
            .offset(
                x = state.offset.x.dp,
                y = state.offset.y.dp
            )
            .graphicsLayer(
                scaleX = state.scaleFactor,
                scaleY = state.scaleFactor
            )
    )
}

This looks fine. But there’s a subtle performance problem here.

When state.offset changes which happens on every single frame while the user is dragging, Compose reads that state inside the SwipeableCard composable body. This means Compose marks the entire SwipeableCard scope for recomposition. It re-runs the composable, re-evaluates the modifier chain, goes through Composition, Layout, and Drawing — all for something that only affects where and how the card is drawn.

You’re paying 💸 for three phases when you only need one.

💡The Fix: Lambda Versions of Modifiers

Both Modifier.offset and Modifier.graphicsLayer have lambda variants:

@Composable
fun SwipeableCard(state: SwipeableCardState) {
    Card(
        modifier = Modifier
            .offset {
                IntOffset(
                    x = state.offset.x.roundToInt(),
                    y = state.offset.y.roundToInt()
                )
            }
            .graphicsLayer {
                scaleX = state.scaleFactor
                scaleY = state.scaleFactor
            }
    )
}

The difference is subtle but significant. The lambda versions defer the state read. Compose doesn’t read state.offset during Composition — it reads it later, during the Layout or Drawing phase, inside the lambda.

This means when the offset changes:

✅ Compose skips Composition entirely
✅ Compose skips Layout (for graphicsLayer)
✅ Only Drawing runs

For a card being dragged around the screen at 60–120fps, this is a meaningful win.

Why Does This Work?

Compose tracks state reads per scope. When you read a state value, Compose registers the current scope as an observer of that state. When the state changes, only the registered scope re-executes.

Lambda modifiers create their own tight scope, just the lambda body. So when the offset state changes, only that lambda re-runs, not the whole composable tree above it.

Going Further: Defer State Reads Down Your Composable Tree 🌳

The same principle applies beyond just modifier lambdas. Consider this pattern:

@Composable
fun CardStack(state: CardStackState) {
    SwipeableCard(
        // state read here, so recomposition scope is CardStack
        scaleFactor = if (state.cards.size > 1) { 
            calculateScale(state.offset) 
        } else 1f,
        canStartDragging = state.cards.isNotEmpty()
    )
}

Here, state.offset and state.cards are read inside CardStack. This means CardStack recomposes every time the drag offset changes — and so does everything inside it.

💡 The fix is to pass lambdas instead of computed values, so the read is deferred until the child composable actually needs it:

@Composable
fun CardStack(state: CardStackState) {
    SwipeableCard(
        scaleFactor = { calculateScale(state.offset) },
        canStartDragging = { state.cards.isNotEmpty() }
    )
}
@Composable
fun SwipeableCard(
    scaleFactor: () -> Float,
    canStartDragging: () -> Boolean
) {
    Card(
        modifier = Modifier.graphicsLayer {
            scaleX = scaleFactor() // state read happens here, in Drawing phase
            scaleY = scaleFactor()
        }
    )
}

Now the state read happens inside SwipeableCard, deep in the modifier lambda. CardStack is no longer an observer of state.offset — so it doesn't recompose when the card is being dragged.

The recomposition scope is smaller. The phases executed are fewer. The performance is better ⬆︎

🧠 The Mental Model to Keep

When you’re writing Compose code and you read a State value, ask yourself:

Does this value change frequently? And does reading it here force more of the tree to recompose than actually needs to?

If yes → defer it. Push the read as far down the tree as possible, ideally into a modifier lambda or a tight composable scope that only cares about that one value.

The rule of thumb 👍

✅ Use Modifier.offset { } and Modifier.graphicsLayer { } (lambda versions) for anything that changes on every frame.

✅ Pass lambdas instead of computed values when the parent shouldn’t own the state observation.

✅ Read state as close to where it’s used as possible, not where it’s convenient.

🔍 How to verify this is working

  1. You should see significant drop in recompositions in your layout inspector
  2. You can setup Macrobenchmark 🧮 tests with FrameTimingMetric — a metric that tracks how many milliseconds frames are overshooting their deadline. The higher the frameOverrunMs, the jankier the experience

Here’s the benchmark test I used to validate and measure the performance improvements:

class JankBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()
    
    @Test
    fun dragCardAround() = benchmarkRule.measureRepeated(
        packageName = PackageName,
        // Tracks how many milliseconds frames are overshooting their deadline
        metrics = listOf(FrameTimingMetric()),
        iterations = 10,
        setupBlock = {
            pressHome()
            startActivityAndWait()
            device.wait(Until.hasObject(By.res(TopCardResId)), 5000)
        },
        measureBlock = {
            // The interaction you want to measure
            val card = device.findObject(By.res(TopCardResId))
            val dragDistance = 210
            val speed = 100
            dragCardAround(card, dragDistance, speed)
        }
    )
}

If you found this useful, the original talk this was based on was presented at Droidcon SF, AndroidMakers and more alongside @TashaRamesh.


📚 Magical Book of Compose Performance — Defer State Reads✨ was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.