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 symptom
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 the obvious approach fails
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 actual mechanism
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.
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:
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
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.