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.

6 min read
androidcomposekotlinperformance

Day 80 — Strong skipping made most of yesterday's advice unnecessary

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.

Strong skipping compares instances rather than requiring stability, and memoises lambdas

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.