Skip to content
Commitwise
Menu
Android Development

Performance and baseline profiles

Make an Android app start fast and scroll smoothly, measure on release builds with Macrobenchmark and Perfetto instead of guessing, avoid unnecessary recomposition in Compose with keys, stable parameters and deferred state reads, keep work off the main thread at startup, and ship baseline profiles so critical code is compiled ahead of time.

advanced

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The shop app takes three seconds to show its home screen on a mid-range phone, and the product list stutters on the first scroll after every install. The team’s reaction was to try things: make images smaller, remove an animation, move a function to a background thread. Some of it helped, nobody knew how much, and two months later the app was slow again.

Performance work that sticks has two parts: measure the right build on a real device, so you know what’s slow and whether a change helped, and fix the few things that matter, which on Android are usually main-thread work, unnecessary recomposition, and code that runs slowly the first time because it hasn’t been compiled yet.

New to some of these? Open what you need, or skip ahead.

Basics:What is the main thread?

The main thread (or UI thread) draws the screen and handles taps. If it’s busy for too long, the app can’t draw the next frame, and the user sees a stutter, called jank. If it’s blocked for about 5 seconds, Android shows “App isn’t responding” (an ANR).

Basics:What is recomposition?

In Jetpack Compose, when state that a composable reads changes, Compose runs that composable again to update the UI. That’s recomposition. It’s normally cheap, but recomposing a lot of UI every frame isn’t. The State and recomposition tutorial covers it.

Part Not started

Smooth scrolling: measure, then fix recomposition

The screen is redrawn 60 times per second on most phones, and 90 or 120 times on newer ones. That gives the app about 16 milliseconds per frame at 60 Hz (8 at 120 Hz) to handle input, recompose, lay out and draw. Anything that doesn’t fit makes a frame late, and the user feels it.

Two rules before fixing anything:

  • Measure release builds. Debug builds are much slower: R8 doesn’t optimize them, Compose runs extra checks, and the code isn’t compiled ahead of time. A debug build stuttering tells you almost nothing.
  • Measure on a real, average phone, not the newest flagship or an emulator.

The most common Compose problems are simple: lists without keys, sorting or formatting data inside composables on every recomposition, and reading values that change every frame (like scroll position) in a way that recomposes a big part of the screen.

Measure with Macrobenchmark (FrameTimingMetric for scrolling, StartupTimingMetric for launch) on a release-like build (benchmark build type: minified, non-debuggable, signed with the debug key) on physical devices, and investigate with Perfetto system traces (with Compose composition tracing enabled to see which composables recompose). JankStats reports jank from production. In Compose, the main levers: skipping (stable or immutable parameters let unchanged composables skip; strong skipping mode, the default since Compose compiler 2.0.20, compares unstable parameters by instance, so new lists created on each recomposition still defeat it), lazy list keys and contentType (correct reuse and item state across reorders), deferring state reads to layout or draw phases with lambda modifiers (offset { }, graphicsLayer { }, drawBehind { }) so fast-changing values skip composition entirely, derivedStateOf when a frequently changing input yields a rarely changing output, and moving computation out of composition into the ViewModel or remember.

@Immutable
data class ProductRow(val id: String, val name: String, val priceText: String, val imageUrl: String)

@Composable
fun ProductList(rows: List<ProductRow>, onClick: (String) -> Unit) {
    val listState = rememberLazyListState()

    // Changes rarely (true/false), even though the scroll index changes constantly
    val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }

    Box {
        LazyColumn(state = listState) {
            items(
                items = rows,
                key = { it.id },                    // stable identity across inserts and reorders
                contentType = { "product" },        // reuse compositions of the same type
            ) { row ->
                ProductRowItem(row, onClick)        // skipped when row and onClick are unchanged
            }
        }
        if (showScrollToTop) ScrollToTopButton(listState)
    }
}

@Composable
fun CollapsingHeader(scrollState: ScrollState) {
    Image(
        painter = painterResource(R.drawable.header),
        contentDescription = null,
        // Read the scroll value in the layout phase, not in composition: no recomposition while scrolling
        modifier = Modifier.offset { IntOffset(x = 0, y = -scrollState.value / 2) },
    )
}
  • key = { it.id } tells Compose which item is which, so inserting a product at the top doesn’t make every row think it changed.
  • ProductRow is @Immutable and already has a formatted priceText from the ViewModel, so the row does no work and is skipped when it hasn’t changed.
  • derivedStateOf turns “the first visible index”, which changes all the time, into “show the button or not”, which changes rarely. Only that changes cause recomposition.
  • Modifier.offset { … } with a lambda reads the scroll value while placing the image, so the header moves every frame without recomposing.
Spot the bugStutter on every scroll

Tap the line with the bug.

@Composable
fun ProductScreen(products: List<Product>, scrollState: ScrollState) {
    val sorted = products.sortedBy { it.name }

    Column(Modifier.verticalScroll(scrollState)) {
        HeaderImage(Modifier.offset(y = (-scrollState.value / 2).dp))
        sorted.forEach { product ->
            ProductCard(
                product = product,
                price = NumberFormat.getCurrencyInstance().format(product.price),
            )
        }
    }
}

Predict the output

A team measures scrolling in a debug build on an emulator and sees 30% janky frames. They measure the same screen with Macrobenchmark on a benchmark build on a mid-range phone. What's most likely?

  • Frame budget ~16 ms at 60 Hz, ~8 ms at 120 Hz. Measure minified non-debuggable builds on physical mid-range devices: Macrobenchmark (FrameTimingMetric), Perfetto with composition tracing, JankStats in production.
  • Compose: skipping needs stable/immutable params (strong skipping compares unstable params by instance, so fresh lists still recompose); lazy keys and contentType.
  • Defer fast-changing reads to layout/draw (offset {}, graphicsLayer {}, drawBehind {}); derivedStateOf for high-frequency inputs with low-frequency outputs.
  • No sorting, formatting or allocation-heavy work in composition; precompute in the ViewModel or remember.

Check this part

  1. Which build should you measure performance on?

  2. What does key = { it.id } in a LazyColumn do?

  3. Why use Modifier.offset { } instead of Modifier.offset(y = …) for a scroll-driven offset?

At 60 Hz a frame has about 16 ms, at 120 Hz about 8 ms. Measure release builds on real mid-range devices (Macrobenchmark, Perfetto, JankStats), never debug builds. In Compose: give lazy list items keys, don't compute heavy things during composition, read fast-changing state in layout or draw lambdas, and keep parameters stable so unchanged items are skipped.

Part Not started

Fast startup and baseline profiles

A cold start is when the app wasn’t running at all: Android creates the process, runs your Application.onCreate, creates the first activity and draws the first frame. Everything you put in Application.onCreate delays that first frame, and teams often put a lot there: analytics, crash reporting, ads, feature flags, a database, all set up “just in case”.

The fixes:

  • Initialize lazily: set something up the first time it’s needed, not at launch.
  • Move work off the main thread when it can’t be lazy.
  • Show something useful early, and tell the system when the screen is really ready with reportFullyDrawn().

Then there’s baseline profiles. Android apps are compiled on the device, and right after install, much of your code runs slowly in an interpreter until the system has seen which code is used and compiled it, which can take days. A baseline profile is a list, shipped in your app, of the classes and methods used at startup and in key screens, so Android compiles them at install time. First launches and first scrolls get noticeably faster.

Startup metrics: TTID (time to initial display: first frame) and TTFD (time to full display: after reportFullyDrawn()), reported in Logcat and Play vitals. Cold-start costs to cut: ContentProvider-based auto-initialization and SDK setup in Application.onCreate (use the App Startup library with lazy initializers, or initialize on a background dispatcher, keeping only crash reporting synchronous), synchronous disk I/O on the main thread (SharedPreferences reads, database opens; catch them with StrictMode in debug), dependency graphs constructed eagerly (Lazy<T> / Provider<T> in Hilt), and heavy first composition. Baseline profiles (baseline-prof.txt, generated by a Macrobenchmark BaselineProfileRule that runs startup and critical user journeys, managed by the Baseline Profile Gradle plugin) are compiled AOT by ART at install via Play’s cloud profiles or ProfileInstaller for sideloads; a startup profile additionally drives R8’s DEX layout so startup classes sit in the primary DEX. Google reports typical improvements around 30% on first launch; verify with StartupTimingMetric using CompilationMode.None() vs CompilationMode.Partial().

// In a :baselineprofile module (com.android.test + androidx.baselineprofile plugin)
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(packageName = "com.example.shop", includeInStartupProfile = true) {
        pressHome()
        startActivityAndWait()                                       // startup

        device.findObject(By.res("product_list")).fling(Direction.DOWN)   // a critical journey
        device.waitForIdle()
        device.findObject(By.text("Cart")).click()
        device.waitForIdle()
    }
}

// Measures the effect: run once without and once with the profile
@RunWith(Parameterized::class)
class StartupBenchmark(private val mode: CompilationMode) {
    @get:Rule val rule = MacrobenchmarkRule()

    @Test
    fun coldStartup() = rule.measureRepeated(
        packageName = "com.example.shop",
        metrics = listOf(StartupTimingMetric()),
        compilationMode = mode,
        startupMode = StartupMode.COLD,
        iterations = 10,
    ) {
        pressHome()
        startActivityAndWait()
    }

    companion object {
        @JvmStatic @Parameterized.Parameters(name = "{0}")
        fun modes() = listOf(CompilationMode.None(), CompilationMode.Partial(BaselineProfileMode.Require))
    }
}
@HiltAndroidApp
class ShopApp : Application() {
    @Inject lateinit var analytics: Lazy<Analytics>          // created on first use, not at launch
    @Inject @ApplicationScope lateinit var appScope: CoroutineScope

    override fun onCreate() {
        super.onCreate()
        CrashReporting.init(this)                            // must be first and synchronous

        appScope.launch(Dispatchers.Default) {
            FeatureFlags.prefetch()                          // not needed for the first frame
            analytics.get().trackAppOpen()
        }
    }
}

// In the first screen: tell the system when content is really there (TTFD)
@Composable
fun HomeScreen(state: HomeUiState) {
    ReportDrawnWhen { state is HomeUiState.Loaded }
    /* … */
}

Production puzzles

Predict the output

A team generates a baseline profile, but StartupBenchmark shows no difference between CompilationMode.None() and Partial(). The app module has no dependency on androidx.profileinstaller and the benchmark installs the APK directly. Why might the profile not be applied?

Predict the output

Application.onCreate reads a 2 MB JSON configuration file from assets and parses it before anything else 'because every screen needs it'. StrictMode is off. What does the user experience on a slow phone?

Kotlin note

Compose’s compiler reports (composeCompiler { reportsDestination = … } in Gradle) list which composables are skippable and which parameters are unstable. Classes from modules not compiled with the Compose compiler (your :core:model without Compose, or third-party types) are unstable by default; mark them stable with a stability configuration file instead of wrapping everything.

Other things worth checking

  • ANRs and slow frames in Play vitals come from real devices; sort by impact.
  • Images: decode at display size (Coil does), never full-resolution bitmaps in lists.
  • Overdraw and nested layouts in legacy View code: flatten with ConstraintLayout.
  • Allocations in onDraw and per-frame lambdas cause GC pauses during animations.
Exercisemedium

Plan a startup fix

An app’s Application.onCreate initializes: crash reporting, an analytics SDK, an ads SDK (used on one screen), Room (used by the home screen), a remote config fetch over the network, and an image loader. The home screen shows a product list from Room. Decide for each one: keep synchronous, move to the background, or make lazy.

›

What does the first frame of the home screen actually need, and what must exist before anything could crash?

SolutionTry it yourself first
  • Crash reporting: synchronous and first, so crashes during startup are reported.
  • Analytics: background, after the first frame. Queue events until it’s ready if needed.
  • Ads SDK: lazy, when the screen that shows ads opens.
  • Room: lazy through Hilt; the first query runs on a background dispatcher anyway, and the home screen can show a placeholder until data arrives.
  • Remote config: background; use cached values from the last launch for this session.
  • Image loader: lazy, created on first use (Coil does this by default with its singleton).

Then measure with StartupTimingMetric before and after, and add a baseline profile covering startup and the first scroll of the product list.

  • TTID and TTFD (reportFullyDrawn/ReportDrawnWhen); cut Application.onCreate work: App Startup with lazy initializers, background init, Lazy<T> in Hilt, no main-thread disk I/O (StrictMode).
  • Baseline profiles: generated by BaselineProfileRule over startup and critical journeys; ART compiles them at install (Play cloud profiles, ProfileInstaller for sideloads). Startup profiles drive DEX layout.
  • Verify with StartupTimingMetric, CompilationMode.None() vs Partial(); confirm baseline.prof is in the release artifact.
  • Compose compiler reports and stability config files for types from non-Compose modules.

Check this part

  1. What does a baseline profile do?

  2. What does reportFullyDrawn (or ReportDrawnWhen) measure?

  3. Why can a sideloaded benchmark APK ignore the baseline profile?

Cold start runs Application.onCreate and the first frame; keep both lean by initializing SDKs lazily or in the background. Report full display with reportFullyDrawn. Baseline profiles list the classes and methods used on startup and critical journeys so the device compiles them ahead of time, typically making first launches and first scrolls noticeably faster.

Key takeaways

  • Measure release-like builds on real phones before changing anything.
  • Give list items keys, and don’t sort or format inside composables.
  • Keep Application.onCreate small; initialize things when they’re needed.
  • Ship a baseline profile so the first launch is fast.
  • Macrobenchmark and Perfetto for evidence; JankStats and vitals for production.
  • Stability, keys, deferred reads and derivedStateOf keep recomposition small.
  • Lazy and background initialization; TTID and TTFD as targets.
  • Baseline and startup profiles, verified with compilation-mode comparisons.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.