A measure-first playbook for senior Android engineers, built around a ferry departure board that kept missing frames.

Every performance story in Compose eventually collapses into the same two sentences. Somebody noticed jank. Somebody else rewrote something clever, and the jank either stayed or moved to a new place.

This post follows a fictional app, Quayside, a live departure board for a harbor ferry operator. It has a long scrolling list, a “jump to top” button, analytics on berth views, and a tide chart fed by a few thousand samples. Each section takes one optimization from the recent Android Developer Summit talk Outperform yourself in Compose and shows what it costs when you apply it carelessly. The goal is not a bag of tricks. It is a habit: predict, measure, keep or revert.

A note on versions. The examples use computedStateOf, which arrives in Compose Runtime 1.13, and keyed SideEffect, which arrived in 1.12. As of this writing 1.12.x is the stable line and 1.13 is at the release-candidate stage, so check your own dependency before copying anything.

The loop that keeps you honest

measure-first-loop

The loop is boring on purpose. Its value is step four: change one thing, and write down the number you expect to move before you run it. A prediction you can be wrong about is what separates engineering from superstition.

Two rules make the loop trustworthy in practice.

First, measure a build that behaves like production. A debug build carries extra checks and disables optimizations, so it inflates costs and can point the finger at innocent code. Create a dedicated build type that mirrors release but stays profileable:

// app/build.gradle.kts android { buildTypes { create("profiling") { initWith(getByName("release")) isDebuggable = false signingConfig = signingConfigs.getByName("debug") matchingFallbacks += "release" } } } 
<!-- AndroidManifest.xml, inside <application> --> <profileable android:shell="true" tools:targetApi="29" /> 

Second, run on a mid-range physical device, not the emulator on your laptop. Frame budgets are decided by the slowest hardware you ship to.

Three lenses, three different questions

tooling-lenses

The talk leans on three tools, and the most common mistake is reaching for the wrong one.

A Perfetto system trace answers “where did this frame go?” You see the frame timeline, what the main thread and RenderThread were doing, and which frames missed their deadline. What you do not see is the name of your composable.

Composition tracing fills that gap. Add the runtime tracing artifact to the profiling build and the Compose portion of the trace gains named slices, so a 9 ms stretch becomes “DepartureRow ran 140 times."

Method tracing goes one level deeper, to the Kotlin inside a slow slice. Its overhead is high enough to distort tiny functions, so treat it as a magnifying glass for a region you have already isolated, not as a first move.

For your own hot paths, a hand-placed trace section costs almost nothing and makes the system trace readable:

import android.os.Trace inline fun <T> traced(label: String, block: () -> T): T { Trace.beginSection(label) try { return block() } finally { Trace.endSection() } } // Usage inside a mapper or ViewModel val rows = traced("Quayside.buildRows") { departures.map(::toRowModel) } 

Keep labels short, since the platform caps their length, and always end the section in finally so an exception cannot leave a dangling slice. If your project already depends on AndroidX Tracing, its trace helper does the same job; the wrapper above simply avoids adding a dependency for one function.

Case one: the jump-to-top button

Quayside shows a floating button once the user scrolls past the first few rows. The model is a plain immutable class:

@Immutable data class Departure( val id: String, val vessel: String, val berth: Int, val departsAt: Instant, val status: Status, ) { enum class Status { OnTime, Delayed, Boarding, Cancelled } } 

The naive version reads the scroll position directly in composition, which invalidates the whole board on every scroll tick:

// Recomposes on every index change, even when the boolean is unchanged val showJumpToTop = listState.firstVisibleItemIndex > 4 

The state you want to observe is the boolean, not the index. Both derivedStateOf and the newer computedStateOf express that. Here is the 1.13 form:

@Composable fun DepartureBoard( departures: ImmutableList<Departure>, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() // Compose 1.13+. On earlier versions use remember { derivedStateOf { ... } }. val showJumpToTop by remember { computedStateOf { listState.firstVisibleItemIndex > 4 } } Box(modifier) { LazyColumn(state = listState) { items(departures, key = { it.id }) { DepartureRow(it) } } if (showJumpToTop) { JumpToTopButton( onClick = { scope.launch { listState.animateScrollToItem(0) } }, ) } } } 

Why the new API exists

computed-vs-derived

derivedStateOf caches its result and tracks the snapshot dependencies needed to know when the cache is stale. That bookkeeping is a fixed cost paid up front. computedStateOf skips the cache and simply recomputes on read, still notifying readers only when the result actually changes.

The numbers reported in the talk tell a balanced story. The computed flavor wins the first read by about 200 ns and wins invalidation by more than 2x. The derived flavor wins repeated reads by about 350 ns. Treat those figures as the talk’s measurements on its own setup, not as constants: nanosecond-level results shift with CPU clocks, thermal state, and the ART compilation state of the app, so reproduce them on your target device before arguing about them. The shape of the trade-off is what matters. A scroll offset changes constantly and the calculation is a single comparison, so invalidation speed dominates and the computed form is the better default. If the calculation is genuinely expensive and read several times per pass, the cache earns its keep and derivedStateOf stays.

Failure modes seen in real code

Writing state inside the block. On 1.13 the calculation lambda is enforced as read-only. A write throws IllegalStateException. Before this, such code compiled and misbehaved quietly, so some codebases will find latent bugs on upgrade:

var scrollTicks by remember { mutableIntStateOf(0) } val atTop by remember { computedStateOf { scrollTicks++ // throws on Compose 1.13 listState.firstVisibleItemIndex == 0 } } 

If you want to count ticks, do it where side effects belong, for example in a snapshotFlow collected from an effect, not inside a derivation.

Using a state value as a remember key. This one defeats the whole purpose and survives code review because it looks tidy:

// Rebuilds the derived state every time the index changes val atTop by remember(listState.firstVisibleItemIndex) { derivedStateOf { listState.firstVisibleItemIndex == 0 } } 

Reading .firstVisibleItemIndex to build a key subscribes the composable to every change, which is exactly what you were trying to avoid. Key on the listState instance if that instance can change, and read the property only inside the derivation.

Wrapping something trivial. A derivation around departures.isEmpty() adds snapshot machinery to a check that costs less than the machinery. Use the expression directly, or a plain Kotlin getter if it is reused.

Case two: analytics that cost more than the screen

Quayside logs a “berth viewed” event when a header appears. The first implementation reached for the effect most people know:

@Composable fun BerthHeader(berth: Int, tracker: BoardTracker) { LaunchedEffect(berth) { tracker.berthViewed(berth) // plain function, never suspends } Text("Berth $berth") } 

LaunchedEffect creates a coroutine and dispatches into it, even when the body never suspends. Multiplied across dozens of headers on a fast scroll, that overhead is visible in a trace. Compose 1.12 added a keyed overload of SideEffect for exactly this shape of work:

@Composable fun BerthHeader(berth: Int, tracker: BoardTracker) { SideEffect(berth) { tracker.berthViewed(berth) } Text("Berth $berth") } 

effect-picker

Failure modes seen in real code

The empty onDispose. Some projects emulated keyed synchronous work with DisposableEffect(berth) { ...; onDispose {} }. It works, but you pay for a disposal contract you do not use. On 1.12 or newer, delete it.

Assuming identical timing. A coroutine launched by LaunchedEffect starts after composition via the dispatcher. SideEffect runs synchronously once the composition is applied. If anything relied on another effect having already finished, the migration can reorder it. Treat the swap as a behavior change and re-test any flow that depends on ordering, which the talk also cautions about.

Putting slow work in a synchronous slot. Removing the coroutine also removes the thread hop. A SideEffect that writes to disk or blocks on a lock now stalls the main thread right where the frame is being finished. Keep it to quick, non-blocking calls; real I/O still belongs in a coroutine.

Expecting it to run for discarded compositions. The block only runs after a successful composition. Work that must happen for an attempt that gets thrown away is the wrong fit, but that is rare, and it usually signals a design problem.

Case three: the price of not recomposing

Skipping feels like free performance. It is not. For Compose to skip a call it must remember the previous arguments, compare them with the new ones, and update its records afterward. That is real work performed on every parent recomposition, whether or not the skip succeeds.

skipping-cost

Quayside’s tide strip takes one large model:

@Immutable data class TideModel( val station: String, val samples: List<TideSample>, // roughly four thousand entries ) { companion object { val Empty = TideModel(station = "", samples = emptyList()) } } @Composable fun TideStrip(model: TideModel, modifier: Modifier = Modifier) { // draws a compact sparkline, cheap per sample } 

A parent ticks once a second for a “last updated” label, and on each tick it re-maps the repository’s samples into a fresh list (for example repository.samples.map(::toSample)) and wraps that in a new TideModel. The content is identical, but the list is a new instance. Because the type is stable, the comparison uses equals, and the generated data-class equals delegates to List.equals. That check returns immediately when both sides are the same list instance, but with two distinct instances it has to walk all four thousand samples to confirm what we already knew. With a light drawing body, the check can cost as much as the work it was supposed to avoid.

Strong skipping compares stable arguments with equals and unstable ones by instance identity. That makes identity the cheap path, so the best fix is often upstream: keep one instance alive across ticks so identity matches, and the same list instance flows through to the equals shortcut described above.

// In the ViewModel: built when the repository emits, never per UI tick val tide: StateFlow<TideModel> = repository.tideSamples .map { samples -> TideModel(station = "North Quay", samples = samples) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TideModel.Empty) 

When you cannot control upstream allocation, give the type a deliberately cheap notion of equality:

@Immutable class TideModel( val station: String, val samples: ImmutableList<TideSample>, val revision: Long, ) { override fun equals(other: Any?): Boolean = other is TideModel && other.revision == revision override fun hashCode(): Int = revision.hashCode() companion object { val Empty = TideModel( station = "", samples = persistentListOf(), revision = 0L, ) } } 

Here the repository, not the UI, owns revision and increments it on every real change, so the mapping step becomes TideModel("North Quay", snapshot.samples.toImmutableList(), snapshot.revision).

This trades a deep comparison for a single long compare, but it moves responsibility onto you. If the repository forgets to bump revision when samples change, the chart goes stale with no crash and no warning. Pair the type with a unit test that fails when samples change but the revision does not.

A field guide to the six traps

failure-modes

Before you merge an optimization

Run the change through a short review, and refuse the merge if any answer is missing.

  • What number was predicted to move, and by roughly how much?
  • Was the before-and-after captured on the same device, with the same gesture, on the profiling build type?
  • Does the trace show the target slice shrinking, or did the cost merely shift to another frame?
  • Is the change version-gated correctly, with a documented fallback for Compose lines older than the one it needs?
  • For effect migrations: was ordering re-tested?
  • For custom equality: is there a test that catches a forgotten revision bump?

A useful side effect of the habit is that some past “optimizations” turn out to be removable once someone finally measures them.

One last tool worth a look is the Compose performance skill mentioned in the talk, installable through the Android CLI. It audits a codebase for patterns like the ones above and proposes fixes. Treat its output the way you would treat a colleague’s pull request: a source of good hypotheses that still have to survive your own trace.

📱 Level Up Your Android System Design Game

If you’re prepping for a senior Android role and system design rounds are the part that makes you sweat, I highly recommend “Mastering Android System Design” — a 37-chapter playbook that treats Android like the constrained runtime it actually is, not a small server. It covers Binder IPC, ART’s GC, process death, and background execution limits, then walks through the CRDDS interview framework and 13 full worked case studies (chat, ride-hailing, video streaming, payments, and more) carried end-to-end through API design, offline sync, and the Android-specific edge cases that trip candidates up mid-interview.