Composition, layout, drawing — and why reading a value in the wrong one costs frames
Compose renders in three phases. Where you read state decides which phase invalidates, and moving a read from composition into layout or draw is the single highest-leverage performance change available.

Day 10 of 100. Day 7 said recomposition re-runs the scopes that read what changed. That was a simplification: it's true of composition, and composition is only the first of three phases.
The symptom
A header that shifts as the user scrolls. Correct, and it janks.
@Composable
fun Header(listState: LazyListState) {
// Reads scroll offset during COMPOSITION
val offset = listState.firstVisibleItemScrollOffset
Text(
"Title",
modifier = Modifier.offset(y = (-offset / 4).dp)
)
}
Scrolling produces a smooth list and a stuttering header. Layout Inspector shows
Header recomposing on every frame of the fling — sixty-plus times a second.
The instinct is that the offset maths is expensive, or that Text is slow. Neither is
true. The work being repeated isn't the arithmetic; it's the entire composition of that
subtree, sixty times a second, to move something a few pixels.

Why the obvious fix fails
The obvious fix is memoisation:
val offset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset / 4 } }
This helps only if the derived value changes less often than its source. Here it
doesn't — the offset genuinely changes every frame, because that's what a smooth
parallax is. derivedStateOf adds an allocation and a comparison, and still
invalidates composition every frame.
The problem isn't how often the value changes. It's that a value changing every frame is being read in the phase that is most expensive to redo.
The actual mechanism
Compose renders each frame in three phases:
- Composition — what to show. Runs your
@Composablefunctions, builds and updates the UI tree. - Layout — where it goes. Measures each node, then places it.
- Drawing — how it renders. Issues the actual draw commands.
The crucial rule: a state read subscribes the phase it happens in.
- Read during composition → the value changing invalidates composition, then layout, then drawing. All three.
- Read during layout → invalidates layout and drawing. Composition is untouched.
- Read during drawing → invalidates drawing only.
So the same value can be cheap or ruinous depending purely on where you read it. That's why several Compose APIs take a lambda where you'd expect a value:
Modifier.offset(y = 10.dp) // value — read in COMPOSITION
Modifier.offset { IntOffset(0, y) } // lambda — read in LAYOUT
That lambda isn't a style preference. It defers the read into the layout phase, so a changing offset never invalidates composition at all.
The fix
@Composable
fun Header(listState: LazyListState) {
Text(
"Title",
modifier = Modifier.offset {
// Read happens during LAYOUT — composition is never invalidated
IntOffset(x = 0, y = -listState.firstVisibleItemScrollOffset / 4)
}
)
}
Header now composes once. Scrolling re-runs layout and draw — which is what moving
something is — and skips the expensive phase entirely.
The same pattern appears throughout the API once you know to look for it:
// Composition-scoped (value) → Layout/draw-scoped (lambda)
Modifier.offset(x, y) Modifier.offset { … }
Modifier.alpha(a) Modifier.graphicsLayer { alpha = a }
Modifier.background(color) Modifier.drawBehind { drawRect(color) }
Modifier.rotate(deg) Modifier.graphicsLayer { rotationZ = deg }
The left column is fine for values that rarely change. The right column is for values that change per frame — animations, scroll, drag, gesture.
Why the phases can't just be one phase
It's fair to ask why Compose bothers with the split, given the complexity it pushes onto the API. The answer is that the phases have wildly different costs, and they depend on each other in one direction only.
Composition allocates: it walks your functions, updates the slot table, creates and disposes nodes. Layout measures and places — arithmetic over an existing tree, no allocation in the common case. Drawing records commands into a display list the GPU consumes. Roughly speaking each phase is an order of magnitude cheaper than the one before it.
Because layout depends on composition's output and drawing depends on layout's, an invalidation always cascades forward, never backward. That's the asymmetry the whole design exploits: invalidate drawing and you pay for drawing; invalidate composition and you pay for all three. Deferring a read one phase later isn't a micro-optimisation, it's skipping the expensive part of the frame entirely.
There's a fourth thing worth knowing about, though it isn't a phase: composition can run on a background thread and be abandoned if its result is superseded. Layout and draw cannot — they're on the UI thread, on the frame clock. Another reason not to put per-frame work into composition: it's the phase with the loosest scheduling guarantees.
The rule of thumb
If a value changes every frame, read it in a lambda. If it changes on user action, either is fine.
This single heuristic covers most Compose performance work in practice, and it's more useful than any amount of profiling, because it prevents the problem rather than finding it.
A worked example — a drag handle that follows the finger:
// Janks: composition runs on every pointer event
var dragY by remember { mutableStateOf(0f) }
Box(Modifier.offset(y = dragY.dp).draggable(…))
// Smooth: only layout runs
val dragY = remember { mutableFloatStateOf(0f) }
Box(Modifier.offset { IntOffset(0, dragY.floatValue.roundToInt()) }.draggable(…))
Note mutableFloatStateOf rather than mutableStateOf<Float> — the specialised
version avoids boxing a Float on every frame. At sixty frames a second that's real
allocation pressure, and it's free to avoid.
How to prove it
Layout Inspector's recomposition counts tell you the composition story directly. Put a counter on the composable and scroll:
@Composable
fun Header(listState: LazyListState) {
val count = remember { mutableIntStateOf(0) }
SideEffect { count.intValue++ }
Log.d("Phases", "Header composed ${count.intValue} times")
…
}
With the value read, the count climbs with every scroll frame. With the lambda read, it stops at one and stays there while the header still moves. That contrast is the entire lesson, and it takes about two minutes to see.
For the layout and draw phases, Modifier.drawWithContent plus a trace, or the
Android Studio profiler's frame timeline, shows where the remaining time goes.
One caveat on measuring: do it on a release build with R8 enabled, and preferably with Baseline Profiles in place. A debug build's composition cost is inflated enough that phase-level differences get lost in the noise, and people routinely "optimise" a debug artefact and see nothing change in production.
What this generalizes to
The idea underneath is granular invalidation: a system that can redo a small part of its work needs to know precisely what depends on what, and the API shape is how you tell it. A value passed eagerly says "I depend on this now". A lambda says "ask me later, in whatever phase you need it".
Once that clicks, lambda-taking APIs across Compose stop looking like inconsistency and start reading as a deliberate signal: this parameter is expected to change often.
The browser made the same trade and arrived at the same vocabulary. Changing a CSS
property that affects width triggers layout, then paint, then composite; changing
transform or opacity skips straight to composite. "Animate transform, not left" is
the identical advice to "read it in a lambda, not a value" — two ecosystems, one
constraint, because both are pipelines where the early stages are the expensive ones.
Which means the skill transfers. When you meet a new rendering system, the first question worth asking is what its phases are and which one your read lands in.
Tomorrow, Day 11: state hoisting — where state should live, and the single question that answers it.
Day 10 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Jetpack Compose phases.