Engineering

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.

MobileByteSensei 8 min read
Day 80 — Strong skipping made most of yesterday's advice unnecessary
androidcomposekotlinperformance

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.

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

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".

Follow by RSS