๐Ÿงช Testing, Debugging, and Performance

Android App Performance Optimization Guide

Performance work fails when it starts with guesses. This guide gives you a measure-first workflow and the highest-impact fixes for startup time, scrolling jank, memory, ANRs, battery and app size, with the Jetpack tools that prove the improvement.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 7 min read ๐ŸŽฏ Intermediate
On this page
  1. The workflow: measure, fix, verify
  2. 1. Startup time
  3. 2. Baseline Profiles
  4. 3. Rendering and jank
  5. 4. Responsiveness and ANRs
  6. 5. Memory
  7. 6. Battery and network
  8. 7. App size
  9. Real-world scenarios
  10. Performance checklist
  11. Frequently asked questions
  12. Conclusion

Users rarely say "this app has a 1.8-second cold start and drops frames in the feed". They say "it feels slow", and some of them uninstall. Performance is a feature, and for Android apps that run on everything from flagship phones to budget devices it's one of the hardest to get right.

This guide is for Android developers who want a systematic approach rather than a list of random tips. It covers the measure-first workflow, the tools for each problem, and the fixes with the biggest real-world impact across startup, rendering, memory, responsiveness, battery and size.

The workflow: measure, fix, verify

Performance optimisation loop A loop of four steps: observe real-world data in Android vitals and Crashlytics; reproduce and measure with Macrobenchmark on a release build; profile with Perfetto or Android Studio Profiler to find the cause; fix and verify with the same benchmark, then guard against regressions in CI. repeat ยท add the benchmark to CI to catch regressions 1. ObserveAndroid vitals 2. MeasureMacrobenchmark 3. ProfilePerfetto / Profiler 4. Fix + verifysame benchmark
Never optimise without a number before and after. Measure release-like builds on a real, ideally mid-range, device.
Don't measure debug builds

Debug builds disable R8 optimisations and add debugging overhead. Jetpack Compose in particular runs noticeably slower in debug. Benchmark a release-like (minified, non-debuggable) build type.

ProblemField signalLab tool
Slow startupAndroid vitals startup metricsMacrobenchmark StartupTimingMetric, Perfetto
Janky scrolling and animationVitals rendering metrics, JankStatsMacrobenchmark FrameTimingMetric, Profiler
ANRsVitals ANR rate (a core vital)StrictMode, Perfetto main-thread traces
Memory leaks and OOMCrash reports (OutOfMemoryError)LeakCanary, Memory Profiler, heap dumps
Battery drainVitals (excessive wake locks, background work)Power Profiler, Background Task Inspector
Large download sizePlay Console size reportsAPK Analyzer, R8 configuration

1. Startup time

Startup is the first impression, and it compounds because users open apps many times a day. Android distinguishes cold starts (process created from scratch), warm starts and hot starts. Cold start matters most, and it is where most improvements come from.

Measure it with Macrobenchmark

benchmark/src/main/java/.../StartupBenchmark.ktKotlin
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    @Test fun coldStartNoCompilation() = startup(CompilationMode.None())
    @Test fun coldStartWithBaselineProfile() = startup(CompilationMode.Partial())

    private fun startup(mode: CompilationMode) = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(StartupTimingMetric()),
        compilationMode = mode,
        startupMode = StartupMode.COLD,
        iterations = 10,
    ) {
        pressHome()
        startActivityAndWait()
    }
}

The highest-impact startup fixes

  1. Do less in Application.onCreate(). Initialise SDKs lazily or after the first frame. Analytics, remote config and image loaders rarely need to block startup.
  2. Add Baseline Profiles (next section). They help the runtime pre-compile critical code paths and are one of the most effective startup and jank improvements.
  3. Avoid disk and network on the main thread during startup: no synchronous SharedPreferences reads of large files, no database queries before the first frame.
  4. Keep the first screen light. Show cached content or a skeleton quickly, then load the rest.
  5. Use the SplashScreen API properly, and don't extend the splash artificially to hide slow initialisation.
Lazy SDK initialisation with HiltKotlin
class App : Application() {
    @Inject lateinit var analytics: dagger.Lazy<Analytics>   // created on first use, not at startup

    override fun onCreate() {
        super.onCreate()
        // Defer non-critical work until after the first frame is drawn.
        Handler(Looper.getMainLooper()).post {
            ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
                analytics.get().initialize()
            }
        }
    }
}

2. Baseline Profiles

Android Runtime (ART) uses just-in-time compilation and profile-guided ahead-of-time compilation. A Baseline Profile ships a list of the classes and methods your critical user journeys need, so they're compiled ahead of time from the first launch instead of being interpreted. Android's documentation reports meaningful improvements in startup and rendering. Measure the gain in your own app with the benchmark above (None vs Partial).

baselineprofile/src/main/java/.../BaselineProfileGenerator.ktKotlin
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(packageName = "com.example.app") {
        // Critical user journey: launch, then scroll the main feed.
        pressHome()
        startActivityAndWait()
        device.findObject(By.res("feed_list"))?.fling(Direction.DOWN)
        device.waitForIdle()
    }
}

Android Studio includes a Baseline Profile Generator module template that sets up the module and the Baseline Profile Gradle plugin for you. Generate the profile on a device or emulator, then commit the generated file.

Compose testTag to resource ID

For UI Automator to find composables with By.res(...), set Modifier.semantics { testTagsAsResourceId = true } near the root and add Modifier.testTag("feed_list") to the list.

3. Rendering and jank

To render smoothly at 60 Hz, each frame must be produced in about 16 ms, and less on 90 or 120 Hz displays. Dropped frames show up as jank during scrolling and animation.

Compose-specific fixes

Compose performance patternsKotlin
// 1. Stable keys and content types in lazy lists
LazyColumn {
    items(items = posts, key = { it.id }, contentType = { it.type }) { post -> PostRow(post) }
}

// 2. Defer reads of fast-changing state to the layout/draw phase with lambda modifiers
Box(Modifier.offset { IntOffset(0, scrollState.value / 2) })      // โœ… no recomposition per pixel
// Box(Modifier.offset(y = (scrollState.value / 2).dp))             // โŒ recomposes every scroll frame

// 3. derivedStateOf when a calculation changes less often than its inputs
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } }

// 4. Remember expensive calculations
val sorted = remember(posts) { posts.sortedByDescending { it.score } }
  • Prefer immutable, stable parameters (data classes with vals, immutable collections) so Compose can skip recomposing unchanged items. Use the Compose compiler reports to find unstable classes.
  • Use Layout Inspector's recomposition counts to find composables that recompose more than expected.
  • Load images at display size with an image library such as Coil or Glide, never full-resolution bitmaps into thumbnails.

View-system fixes

  • Flatten deep layouts (for example with ConstraintLayout) and avoid nested weights.
  • Use DiffUtil / ListAdapter and avoid notifyDataSetChanged().
  • Never bind expensive work in onBindViewHolder.

4. Responsiveness and ANRs

An ANR happens when the main thread is blocked for too long, for example for about five seconds while handling input. ANR rate is one of Google Play's core vitals, and exceeding Play's bad-behaviour thresholds can affect your app's visibility. The usual causes are I/O, locks or heavy computation on the main thread.

Catch main-thread I/O early with StrictMode (debug only)Kotlin
class App : Application() {
    override fun onCreate() {
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build()
            )
        }
        super.onCreate()
    }
}

The fix is almost always to move the work off the main thread with coroutines (withContext(Dispatchers.IO) or Dispatchers.Default), and to avoid runBlocking in app code. See Kotlin Coroutines and Flow Explained.

5. Memory

  • Find leaks with LeakCanary in debug builds. Common culprits are Activities captured by long-lived objects, unregistered listeners, and Views held by Fragments after onDestroyView.
  • Never hold a Context, View or Activity in a ViewModel or singleton. Use the application context when you truly need one.
  • Bitmaps are the biggest memory consumer in most apps. Downsample to display size, and let image libraries manage caching.
  • Respond to onTrimMemory by clearing caches when the system is under pressure.

6. Battery and network

  • Use WorkManager with constraints (unmetered network, charging) for deferrable background work, instead of custom wake locks and alarms.
  • Batch and cache network requests. Every radio wake-up costs battery. Use HTTP caching and avoid polling. Prefer push (FCM) for updates.
  • Stop location and sensor updates when the screen isn't visible, using lifecycle-aware collection.

7. App size

app/build.gradle.ktsGradle KTS
android {
    buildTypes {
        release {
            isMinifyEnabled = true          // R8: shrink, optimise, obfuscate code
            isShrinkResources = true        // remove unused resources
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
  • Publish an Android App Bundle so Google Play delivers only the code and resources each device needs.
  • Inspect the output with the APK Analyzer. Large native libraries, unused fonts and uncompressed images are common surprises.
  • Prefer vector drawables and WebP or AVIF images where appropriate. Remove unused dependencies.

Real-world scenarios

Slow cold start after adding SDKs

Diagnosis
A Perfetto trace shows several SDK initialisers in Application.onCreate().
Fix
Lazy and deferred initialisation, plus a Baseline Profile for the launch journey.
Verify
The StartupBenchmark median improves and stays stable in CI.

Janky feed in Compose

Diagnosis
Recomposition counts show every row recomposing on scroll; an unstable List parameter.
Fix
Stable keys, an immutable UI model, deferred state reads, and properly sized images.
Verify
FrameTimingMetric shows fewer slow frames.

ANR spike after a release

Diagnosis
Vitals ANR clusters point to a synchronous database read in a click handler.
Fix
Move the read to a suspend function and show a loading state.
Verify
StrictMode stays clean; the ANR rate falls in the next release.

Performance checklist

  • โ˜ Startup and scroll Macrobenchmarks exist and run on release-like builds
  • โ˜ Baseline Profile generated for critical journeys and committed
  • โ˜ No SDK initialisation that isn't needed for the first frame
  • โ˜ StrictMode enabled in debug; no main-thread disk or network
  • โ˜ LeakCanary in debug builds; no known leaks
  • โ˜ Lazy lists use stable keys; images loaded at display size
  • โ˜ R8 and resource shrinking enabled; App Bundle published
  • โ˜ Android vitals reviewed after every release

Frequently asked questions

What is a good cold start time?

Faster is better, and Android vitals flags startups it considers excessive. Rather than chase a universal number, measure your current median on a mid-range device, set a target, and prevent regressions in CI.

Do Baseline Profiles work for all users?

They're delivered with your app through Google Play and applied by the Android Runtime on supported Android versions. Android's documentation lists the details. Measure the impact with Macrobenchmark using different compilation modes.

Is Jetpack Compose slower than Views?

Not inherently. Both can be fast or slow. Compose performance depends heavily on state handling and stability, and must be judged on release builds, ideally with Baseline Profiles.

Should I add a performance-monitoring SDK?

Android vitals and Crashlytics cover the essentials for many apps. Additional monitoring can help, but every SDK has its own startup and size cost, so measure it.

Conclusion

Android performance work is a loop: observe field data, measure with Macrobenchmark, profile to find the cause, fix, and verify with the same benchmark. The highest-impact fixes are usually the same few: less work at startup, Baseline Profiles, nothing blocking the main thread, stable Compose state, sensibly sized images, and R8 for size.

Next step: add the StartupBenchmark above to your project today and record your baseline number. Every optimisation after that becomes measurable. For the architecture that makes these fixes easier, read Clean Architecture in Android.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Performance work starts with understanding how Android apps are built and how they behave at runtime. Strengthen those fundamentals and the profiling tools start to make sense.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp