Compose is doing what you told it; the instruction was wrong
Recomposition scope is set by where a value is read, not where it is declared. Most Compose performance problems are a read placed higher than it needed to be.
A screen recomposes more than it should. A value changes thirty times a second — a scroll offset, an animation, a text field — and a composable several levels away rebuilds each time, along with everything inside it.
Nothing is behaving incorrectly. The framework is recomposing exactly what was told to depend on that value, and the telling was accidental.
The case this post is about
One screen, ProfileScreen, with a collapsing header. The header shrinks as the list scrolls, so it
needs the scroll offset — a value that changes on every frame of a fling:
@Composable
fun ProfileScreen(vm: ProfileViewModel) {
val listState = rememberLazyListState()
val offset = listState.firstVisibleItemScrollOffset // ← READ here, at the top
Column {
CollapsingHeader(offset = offset)
ProfileList(state = listState, items = vm.items) // ← recomposes too, for no reason
}
}
ProfileList does not use offset. It does not mention it. It recomposes on every frame anyway,
because the read happened in ProfileScreen, and the whole of ProfileScreen's body is the scope
that read belongs to.
Layout inspection on a fling showed ProfileList recomposing ~60 times a second against 0 changes
to vm.items. That is the case carried through the rest of this post.
The symptom
The visible version is jank: a list that stutters while something unrelated animates, or a screen that drops frames during scrolling. The usual first response is to look for expensive work inside the composable that is stuttering — and often there is none, because the expense is not in any one recomposition but in how many of them there are.
The second thing is that the culprit is almost never near the symptom. The value changing is in one place; the composable rebuilding is somewhere else entirely; and the connection between them is a read that happens high in the tree, which causes everything below it to be in scope. Looking at the slow composable tells you nothing, because the slow composable is innocent.

The third is that it is invisible in the code's shape. A composable that reads a value at the top and passes it down four levels looks identical, on the page, to one that passes a lambda down four levels and reads it at the bottom. Same arguments, same nesting, same number of lines — and completely different recomposition behaviour.
The fourth is that the usual mental model points the wrong way. People reason about where state lives and where it changes, because that is how state management is normally discussed. Neither determines scope. The only thing that determines what recomposes is where the value is read, and that is a fact about the call site rather than about the state.
Why the obvious fix failed
Memoise the expensive composable. The reflex, and it treats the cost of each recomposition rather than the number of them. It sometimes helps and it leaves the structure intact, so the next value added at the same level recreates the problem.
Split the composable into smaller ones. Good practice generally and not sufficient on its own, because splitting changes nothing if the read stays where it was. A composable that reads a value at the top and calls six small children still recomposes all six.
Hoist the state higher. The instinct from "lift state up", applied to a different problem. Moving the state higher usually moves the read higher too, which widens the scope rather than narrowing it — so the standard advice, applied without the distinction, makes this worse.
Reach for the profiler and optimise what it shows. Useful, and it reports which composables recomposed rather than why they were in scope. You get a list of symptoms ordered by cost, which is exactly the information that sends you to optimise the innocent composable at the top of it.
The actual mechanism
Scope is set by the read. A composable recomposes when a value it reads changes — and "reads" means the actual access, not the declaration, not the passing, not the containing function.
So the fix is to move the read down, as close as possible to the thing that genuinely needs the value, by passing something that defers it rather than the value itself.
MyRow(offset = scrollState.value) the caller reads; the caller recomposes
MyRow(offset = { scrollState.value }) the callee reads; only the callee recomposes

That is the whole technique and it is almost entirely about where the access sits. Passing a lambda rather than a value does not defer computation in any interesting way; it defers the read, and the read is what the framework is tracking.
The sub-case that trips people up: this applies to the modifier chain as much as to content. A modifier that takes a value computed at the call site puts that read in the caller's scope; one that takes a lambda evaluated during layout or draw does not. Two modifiers that look equivalent on the page can differ by an entire subtree of recomposition, and the difference is not visible in their names.
There is a related point about what recomposition actually costs. Not every recomposition is expensive — the framework skips composables whose inputs have not changed, and much of the work is cheap. The problem case is specifically a frequently-changing value read high in a tree, where the skipping cannot help because the inputs genuinely did change for everything in scope.
The fix
find which value changes at high frequency
find where it is read, which is not where it is declared
move the read downward by passing a lambda rather than a value
check the modifier chain, where the same rule applies invisibly
On ProfileScreen, the whole fix is the type of one parameter. offset: Int becomes
offset: () -> Int, and the read moves from the caller into the only composable that wanted it:
@Composable
fun ProfileScreen(vm: ProfileViewModel) {
val listState = rememberLazyListState()
Column {
// pass the READ, not the VALUE — nothing is read in this scope now
CollapsingHeader(offset = { listState.firstVisibleItemScrollOffset })
ProfileList(state = listState, items = vm.items)
}
}
@Composable
fun CollapsingHeader(offset: () -> Int) {
// the read happens HERE, so this is the only scope that invalidates
val height = (HeaderMax - offset().dp).coerceAtLeast(HeaderMin)
Box(Modifier.height(height)) { /* … header content … */ }
}
ProfileScreen no longer reads firstVisibleItemScrollOffset, so it is no longer in scope for it,
so ProfileList is not either. Same arguments, same nesting, same number of lines — one changed
type.
The modifier version of this is the same rule and much easier to miss, because a modifier looks like configuration rather than code:
// reads offset in the CALLER's scope — the whole parent invalidates
Box(Modifier.offset(y = offset.dp))
// defers the read into layout — only this node re-lays-out
Box(Modifier.offset { IntOffset(0, offset()) })
The second line is the one that takes practice. "Where is this read" is a different question from "where does this come from", and answering it means tracing the value forward to its actual access rather than backward to its origin — which is the opposite direction from normal debugging.
What makes this tractable is that the change is usually tiny. The restructuring people fear — lifting components out, introducing new state holders, splitting files — is rarely needed. Most of these are one parameter changing from a value to a lambda.
There is a second shape of the same mistake that is worth recognising separately, because the fix is different. Rather than reading a frequently-changing value too high, a composable takes a parameter whose identity changes every recomposition — a lambda created inline, a list rebuilt each time, an object constructed in the call. The value is semantically the same and the framework cannot know that, so everything downstream is treated as changed.

The first problem is about where you read; this one is about what you pass. They produce the same symptom and they are fixed in opposite directions — one by moving a read down, the other by making a parameter stable where it is. Telling them apart is a matter of asking whether the input genuinely changed or merely looks new, which recomposition counts alone will not answer.
How we proved it
The mechanism is directly observable with recomposition counts: instrument the tree, change the value, and read which composables rebuilt. That tells you the scope empirically rather than by reasoning, and it is how you confirm that the read, not the declaration, is what set it.
The lambda change is verifiable in isolation. Make exactly one parameter a lambda, change nothing else, and the recomposition count for everything above the read drops to zero. One-line change, direct before-and-after, no other variables — which is the kind of evidence this sort of claim usually lacks.
On ProfileScreen, one fling, counts from the Layout Inspector:
offset: Int offset: () -> Int
ProfileScreen 61 1
CollapsingHeader 61 61
ProfileList 61 1
(48 item rows) 61 1
CollapsingHeader still recomposes every frame — it is the thing that genuinely depends on the
offset, and it should. What changed is that nothing else does. The 48 rows were never interested in
the scroll offset and were rebuilding sixty times a second because of where one value was read.

What I have not measured is frame timing on a real device for a real screen. My evidence here is recomposition counts, which are the mechanism rather than the user-visible outcome. Fewer recompositions is not automatically a smoother screen, and I have not closed that gap with measurements — only with the reasonable assumption that doing less work helps.
The lambda parameter is not free, and I should say so since I have recommended it four times. It allocates, it is slightly harder to read than a plain value, and it moves work from composition into layout or draw — which is usually what you want and is occasionally not, if the thing being deferred is itself expensive. On a read that happens once per frame in one place, none of that matters; applied reflexively across a screen, it is a real cost for no benefit.
What I have not solved: finding these systematically. Everything above is about fixing one once you suspect it. I have no way to scan a codebase for high-frequency values read above where they are needed, which is the check I actually want, and I am not sure it is statically decidable — "high frequency" is a runtime property.
The thing that made this click for me was realising the framework has no concept of a composable "using" a value in the sense I was reasoning about. It does not inspect what a function does with its arguments or where state is declared. It records, at runtime, which composable was executing when a snapshot value was accessed — and that record is the dependency. Everything about the behaviour follows from that one implementation fact, including the parts that feel surprising.
Once the model is "whoever touched it is subscribed", the fixes stop being tricks and become obvious. Passing a lambda works not because lambdas are special but because the touch happens inside it. That reframing is worth more than any individual technique, because it predicts the next case instead of enumerating the known ones.
And a caution about over-applying it: turning every parameter into a lambda makes code harder to read and defers reads that were never a problem. The technique has a narrow target — frequently changing values, read high — and applied everywhere it is a readability cost paid for no benefit. The profiler is the right instrument for finding the target even though it is the wrong one for diagnosing the cause.
The reason the profiler misleads is worth one more sentence, because it is the tool everybody reaches for and it is answering a slightly different question. It tells you which composables recomposed and how often — the symptom, ranked by cost. The cause is which composable read the value, and that one is usually cheap, often near the top of the tree, and rarely at the top of the profiler's list.
So the profiler is the right instrument for noticing there is a problem and the wrong one for finding it. The sequence that works is: profile to see that something is recomposing far more than it should, then stop looking at the ranking and go and find the read.
What this generalizes to
The useful distinction is between where a value lives and where it is observed, and reactive systems are generally scoped by the second while people reason about the first.
That mismatch is not specific to one framework. Observable properties, computed signals, dependency tracking in reactive stores, subscriptions in event systems — in each case the dependency graph is built from reads, and the mental model people bring is built from declarations. The code looks like a hierarchy of ownership and behaves like a graph of observations.
So when something is updating more than it should, the question to ask is not what changed or where it is stored. It is who is reading it, and could that read happen somewhere narrower — because the reader is the thing the system is actually tracking, and moving the read is usually a much smaller change than restructuring the ownership.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".