Strong skipping made most of yesterday's advice unnecessary
Strong skipping lets composables skip even with unstable parameters, using instance comparison, and memoises lambdas automatically. It removes most of the need for @Immutable annotations and changes what the compiler report means.
Day 80 of 100. Yesterday described a model that a compiler mode has substantially changed — and knowing which parts survived is the difference between useful advice and cargo cult.
The case this post is about
Three annotations, added to fix recomposition, that no longer do anything:
@Immutable data class Order(val id: String, val items: List<LineItem>)
@Immutable data class LineItem(val sku: String, val qty: Int)
@Immutable data class Money(val amount: Long, val currency: String)
Those were load-bearing. Without them the row taking an Order could not skip at all, so the
annotations were the difference between a list that scrolled and one that did not.
Under strong skipping they are mostly redundant: an unstable parameter no longer prevents skipping,
it changes the comparison from equals to instance identity. The row skips whenever it is handed
the same Order instance, annotated or not.
This post follows those three lines, because the advice did not disappear — it inverted. The fix is now to stop rebuilding the instance, and the annotation is the fallback for when you cannot.
The symptom: annotations that no longer do anything
A codebase full of annotations that no longer do anything:
@Immutable data class Order(val id: String, val items: List<LineItem>)
@Immutable data class LineItem(val sku: String, val qty: Int)
@Immutable data class Money(val amount: Long, val currency: String)
Every model class annotated, defensively, because a blog post from 2023 said unstable
types cause recomposition. Nobody has measured whether any of them mattered, and two are
now lying — Order holds a List that a repository mutates.
Meanwhile the app's real recomposition problem is a lambda in a list row, which no annotation addresses. Or it's a parent invalidating on a scroll offset, which no annotation addresses either.
Why annotating everything can in fact hurt
The obvious approach is to keep annotating everything, on the theory that it can't hurt.
It can. Each @Immutable is an unverifiable promise, and a broken one produces a UI that
doesn't update — the hardest class of bug to trace, because nothing errors and the data is
correct.
More to the point, with strong skipping enabled most of those annotations change nothing at all. They're maintenance burden with no effect.

The mechanism: strong skipping, on by default
Strong skipping — on by default since Compose Compiler 1.5.4 era, and standard in current versions — changes two things.
1. Unstable parameters no longer prevent skipping.
Previously, a composable with any unstable parameter was not skippable at all. Under strong skipping it is skippable, and the comparison rule differs by parameter:
- Stable parameters — compared with
equals, as before. - Unstable parameters — compared by instance identity (
===).
So OrderRow(order) skips when it's handed the same instance of Order, even though
Order is unstable. In a list backed by a stable data source, that's the common case, and
it's why the annotations stopped mattering.
2. Lambdas are memoised automatically.
ItemRow(item = item, onClick = { viewModel.select(item.id) })
The compiler now wraps that lambda in a remember keyed on its captures. Yesterday's
"capture something stable" advice is still good hygiene, and it's no longer the difference
between skipping and not.
The two comparison rules are four lines, and running them is how the one remaining failure mode becomes visible:
data class Param(val name: String, val stable: Boolean, val previous: Any?, val current: Any?)
fun skips(params: List<Param>): Pair<Boolean, List<String>> {
val reasons = mutableListOf<String>()
params.forEach { p ->
val same = if (p.stable) p.previous == p.current // equals
else p.previous === p.current // identity
if (!same) reasons += "${p.name} changed (${if (p.stable) "equals" else "identity"})"
}
return reasons.isEmpty() to reasons
}
The row composable across three parent recompositions, where order is an unstable Order:
val a = Order("4711"); val b = Order("4711") // equal contents, different instances
println(skips(listOf(Param("order", stable = false, previous = a, current = a))))
println(skips(listOf(Param("order", stable = false, previous = a, current = b))))
println(skips(listOf(Param("order", stable = true, previous = a, current = b))))
(true, [])
(false, [order changed (identity)])
(true, [])
Line 1 is the case that made the annotations stop mattering: the same instance, handed down from a
state holder that did not rebuild it, skips despite being unstable. Line 2 is the remaining failure
and it is entirely about where the object is constructed — a map in the composable body, a
copy() per frame, a DTO re-created from a flow emission all produce an equal-but-new instance, and
identity comparison correctly says it changed. Line 3 is what @Immutable buys you in that case:
the comparison moves back to equals and the equal-contents instance skips again.
So the advice inverted rather than disappeared. Before strong skipping, the fix was to annotate the type. Now the fix is to stop rebuilding the instance, and the annotation is the fallback for when you cannot.
What this changes in practice
Three concrete shifts:
Stop annotating defensively. Add @Immutable/@Stable when you've measured a problem
and the annotation is honestly true — not as a matter of course. A model layer with no
annotations is now the normal state of affairs.
The report reads differently. Under strong skipping, composables previously marked
non-skippable now show as skippable. unstable on a class is no longer automatically a
problem; it's information about how the comparison happens.
Instance identity now matters where equality used to. This is the new failure mode, and it's the one to internalise.
The new failure mode
Because unstable parameters compare by identity, creating a new instance each composition defeats skipping — even when the value is identical:
@Composable
fun OrderScreen(orders: List<Order>) {
// A NEW list instance every composition → never equal by identity
OrderList(orders = orders.filter { it.isActive })
}
filter allocates. The resulting list is a different instance every time, so OrderList
never skips, regardless of stability annotations.
The fix is the one that was always right:
val active = remember(orders) { orders.filter { it.isActive } }
OrderList(orders = active)
Same instance while orders is unchanged. This is Day 8's remember(keys) contract, and
under strong skipping it's load-bearing for performance rather than merely tidy.
The general form: derive-in-place allocates, and allocation breaks identity comparison.
Anything computed in a composable body and passed down should be remembered with the
inputs as keys.
Where stability still matters
Not everywhere, but not nowhere:
Stable types compare by equals, which succeeds across instances. A stable Money
constructed fresh each composition still compares equal and still skips. An unstable one
wouldn't. For small value types this is a real reason to keep them stable.
@Stable on interfaces still communicates a contract — that implementations notify on
change — which matters for state holders and repository interfaces exposed to composables.
Strong skipping can be disabled, and some projects on older toolchains still have it off. Checking is one line in your compiler options, and worth doing before applying either day's advice.
The escape hatch, and when it's right
Strong skipping adds a @NonSkippableComposable annotation for the rare case where you
want a composable to re-run every time:
@NonSkippableComposable
@Composable
fun DebugOverlay(state: AppState) { … }
Legitimate for a diagnostic overlay that must reflect the very latest values, or a composable whose correctness depends on running each frame. Rare, and worth a comment when it appears — a reader's default assumption is that skipping is desirable.
The complement is @DontMemoize on a lambda you specifically want reallocated, which is
rarer still and usually indicates the surrounding design should change instead.
Neither day helps with invalidation
Worth repeating because it survives every compiler change: skipping is about a composable that has been invoked. If a parent invalidates 60 times a second because it reads a scroll offset, its children skip 60 times a second — cheaply, but the parent still runs.
Moving the read down (Day 7) or deferring it to a later phase (Day 82) is the fix for that, and no compiler mode changes it.
How to prove it: confirm the mode first
Confirm the mode first:
composeCompiler {
// strong skipping is on by default in current versions;
// reportsDestination is what you actually want to set
reportsDestination = layout.buildDirectory.dir("compose-reports")
}
Then the identity claim, which is the one worth seeing directly:
@Composable
fun Parent(orders: List<Order>) {
SideEffect { Log.d("Skip", "parent composed") }
OrderList(orders.filter { it.isActive }) // allocates
// vs
OrderList(remember(orders) { orders.filter { it.isActive } })
}
Log inside OrderList and force the parent to recompose. The first version logs every
time; the second logs once. That contrast is strong skipping's whole behaviour in one
experiment. Running it once is worth more than reading either of these two posts.
What this generalizes to: performance guidance has a shelf life
The lesson is partly about Compose and mostly about advice: performance guidance has a version, and the toolchain moves. A lot of the stability folklore in circulation predates strong skipping and is now either unnecessary or actively harmful.
The durable parts are the ones that were never really about the compiler — don't allocate in a composable body, pass the smallest thing that renders, measure before optimising. Those held before strong skipping and hold after, which is a reasonable test for whether a piece of performance advice is worth memorising at all.
There's a practical version of that test: if a technique's justification requires naming a compiler flag, check the flag before applying the technique. If its justification is "don't do unnecessary work in a hot path", it's probably safe to keep believing.
Tomorrow, Day 81: Baseline Profiles — the optimisation that isn't about recomposition at all.
Day 80 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Strong skipping.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".