Nine pillars have quietly been teaching you one optimisation

Reading a state value later — in a lambda rather than as a parameter — moves the invalidation from composition to layout or draw. It is the single highest-leverage Compose optimisation, and it has one shape.

6 min read
androidcomposekotlinperformance

Day 82 — Nine pillars have quietly been teaching you one optimisation

Day 82 of 100. This technique has appeared on Days 10, 57, 62, 72 and 74. Today it gets its own post, because recognising the shape is worth more than any individual instance.

The symptom

A screen that recomposes sixty times a second while nothing changes structurally:

@Composable
fun ParallaxHeader(listState: LazyListState) {
    val offset = listState.firstVisibleItemScrollOffset      // read in COMPOSITION
    Image(
        painter = heroPainter,
        contentDescription = null,
        modifier = Modifier
            .offset(y = (-offset / 3).dp)
            .alpha(1f - (offset / 600f).coerceIn(0f, 1f)),
    )
}

Layout Inspector shows ParallaxHeader recomposing on every scroll frame. The image doesn't change, its size doesn't change — only its position and opacity — and yet the whole composable re-executes.

Why the obvious fix fails

The obvious fixes are the two from Days 79 and 80: make things stable, split the composable smaller.

@Composable
fun ParallaxHeader(listState: LazyListState) {
    val offset = listState.firstVisibleItemScrollOffset
    HeaderImage(offset = offset)       // extracted, stable Int parameter
}

HeaderImage now takes a stable Int, so it could skip — except the value genuinely changes every frame, so it doesn't. And ParallaxHeader still recomposes 60 times a second, because it's the one doing the reading.

Stability governs skipping. This is an invalidation problem, and no amount of skipping work touches it.

Where you read a value decides which phase invalidates — composition, layout or draw

The actual mechanism

Day 10's rule, stated once more because everything below follows from it: a state read subscribes the phase it happens in.

  • Read during composition → composition, layout and draw all re-run.
  • Read during layout → layout and draw re-run. Composition is untouched.
  • Read during draw → only draw re-runs.

So the fix isn't to make the composition cheaper. It's to not run it at all, by reading the value later:

@Composable
fun ParallaxHeader(listState: LazyListState) {
    Image(
        painter = heroPainter,
        contentDescription = null,
        modifier = Modifier
            .offset {                                   // LAYOUT phase
                IntOffset(0, -listState.firstVisibleItemScrollOffset / 3)
            }
            .graphicsLayer {                            // DRAW phase
                alpha = 1f - (listState.firstVisibleItemScrollOffset / 600f).coerceIn(0f, 1f)
            },
    )
}

ParallaxHeader now composes once. Scrolling re-runs layout and draw, which is what moving and fading something actually is.

The API shape to recognise

Once you know to look, the pattern is visible in the signatures. Compose offers a lambda-taking variant wherever a value is expected to change often:

Reads in composition Reads later
Modifier.offset(x, y) Modifier.offset { IntOffset(…) }
Modifier.alpha(a) Modifier.graphicsLayer { alpha = a }
Modifier.rotate(deg) Modifier.graphicsLayer { rotationZ = deg }
Modifier.scale(s) Modifier.graphicsLayer { scaleX = s }
Modifier.background(color) Modifier.drawBehind { drawRect(color) }
Modifier.padding(p) Modifier.layout { … }
Text(text = value) BasicText(text = { value })

The left column is correct for values that change on user action. The right column is for values that change per frame.

A lambda parameter where you'd expect a value is a signal: this parameter is expected to change often, and the API is offering you a later read. That's the reading habit worth forming — it explains a dozen otherwise-arbitrary-looking signatures.

graphicsLayer is the workhorse

For anything visual, graphicsLayer is the single most useful deferred-read API, because it covers most of what an animation touches:

Modifier.graphicsLayer {
    alpha = animatedAlpha
    scaleX = animatedScale
    scaleY = animatedScale
    rotationZ = animatedRotation
    translationX = animatedX
    translationY = animatedY
}

All six read in the draw phase. An animation driving all of them costs zero recompositions.

The version taking a value rather than a lambda — Modifier.graphicsLayer(alpha = x) — reads in composition, so the lambda form is the one to reach for by default.

Where it doesn't apply

Being precise, because "defer everything" is the wrong lesson:

Structural changes must be in composition. If a value decides what is emitted — an if, a list's contents, which composable to call — composition has to run. There's no deferring that, and trying to is how you get a Canvas reimplementing a layout.

Text content is a composition read, mostly. BasicText has a lambda overload, but a changing string genuinely changes the layout, so the saving is smaller than for a transform.

Values that change on user action don't need it. A colour that changes when a button is tapped invalidates composition once. Deferring it adds indirection for nothing.

And a deferred read still costs something. graphicsLayer promotes the element to its own render layer, which is cheap but not free — wrapping every element in one "for performance" allocates layers that then have to be composited. Use it where a value actually changes per frame.

The rule from Day 10 remains the whole heuristic: if a value changes every frame, read it in a lambda. If it changes on user action, either is fine.

The related habit: don't allocate in composition

Adjacent to deferring, and worth pairing with it. Work done in a composable body runs every time the body runs:

@Composable
fun OrderRow(order: Order) {
    val formatted = currencyFormatter.format(order.total)      // every composition
    val sorted = order.items.sortedBy { it.name }              // allocates, every composition
    …
}

remember with the right keys fixes both, and Day 80 made the second one a skipping problem as well as a cost problem — a freshly-sorted list is a new instance, so anything downstream stops skipping.

Deferring reduces how often composition runs; remember reduces what it costs when it does. Different levers, frequently needed together.

How to prove it

The measurement is direct and the contrast is stark:

@Composable
fun ParallaxHeader(listState: LazyListState) {
    SideEffect { Log.d("Perf", "header composed") }
    …
}

Scroll for two seconds. The value-read version logs 100-plus times; the lambda version logs once. Layout Inspector's recomposition counts show the same thing without a log.

For the frame cost, Macrobenchmark's FrameTimingMetric on a scroll — Day 78's harness — gives the number that matters. A parallax header moved from composition to draw is frequently worth several milliseconds at P99, which on a 120Hz device is most of the budget.

What this generalizes to

The principle is subscribe as late as possible. A dependency declared early is a dependency the system must assume is load-bearing; declared late, it constrains only the stage that actually needs it.

That's the same instinct as lazy evaluation, as passing a function rather than a computed value, and as narrow interfaces — each one defers a commitment so the system has more room to skip work. Compose makes it unusually concrete: the phase in which you read is the phase that pays, and moving the read one line changes which one that is.

It's also the rare optimisation with no downside worth naming. Stability annotations carry a promise; remember costs a slot; a baseline profile costs install size. Moving a read into a lambda costs a pair of braces.

Tomorrow, Day 83 closes the performance pillar with the tools — Layout Inspector, Macrobenchmark and the compiler reports, used together.


Day 82 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Defer reads as long as possible.