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.

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.

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.