Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 050 / 100

Android app performance: real-world lessons and examples

Smooth, fast apps.

Expona Admin7 min read

Introduction

Slow apps lose users. We've all experienced that frustrating moment when a screen takes 500ms to appear, or a list stutters when you scroll. In this article, we'll explore Android app performance: real-world lessons and examples drawn from production systems handling millions of transactions. Performance isn't an afterthought—it's a design constraint we need to bake in from day one. The good news is that most performance wins come from understanding the fundamentals and applying them consistently, not from exotic tricks.

Startup — Baseline profiles and lazy initialization

App startup time directly correlates with retention. If your app takes more than three seconds to become interactive on a cold start, users will switch. We've seen e-commerce apps shed 2–3% of conversions per 100ms of startup delay. The two main levers are baseline profiles and lazy initialization.

Baseline profiles tell the Android runtime which methods are hot paths worth JIT-compiling ahead of time. Instead of waiting for the runtime to discover and compile your frequently-called methods during app startup, you ship pre-compiled profiles. The result is typically a 20–30% reduction in startup time on cold starts.

To create a baseline profile, use the Android Gradle macrobenchmark plugin. You write a startup benchmark that mimics real user behavior:

kotlin
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.example.myapp",
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD,
        setupBlock = {
            killProcess()
        }
    ) {
        startActivityAndWait()
    }
}

Run this benchmark with the profile generator instrumentation, and the Gradle plugin will generate a baseline profile stored in src/main/baselineProf/baseline-prof.txt. Ship this with your APK.

Lazy initialization is equally critical. On startup, load only what the user needs right now. If your app loads analytics SDKs, crash reporting, feature flags, and ad networks synchronously on the main thread during Application creation, you're leaving tens or hundreds of milliseconds on the table. Use background threads or coroutines to defer non-critical work:

kotlin
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        
        // Critical path only
        initializeDependencyContainer()
        
        // Defer analytics and non-blocking setup
        Thread {
            Analytics.initialize(this)
            CrashReporting.initialize(this)
        }.start()
    }
}

In production SaaS apps, we've found that deferring third-party SDK initialization cuts startup time by 40–60% with zero functional impact on the user experience.

Recomposition — Stable types and remember to avoid extra work

If you're using Compose (and by Day 50 of modern Android development, you should be), recomposition is both a blessing and a footgun. Recomposition runs your composable functions again to detect UI state changes. If you're not careful, you trigger unnecessary recompositions that cause jank and battery drain.

The root cause is usually unstable types. If your data class is not recognized as stable, Compose assumes it has changed on every recomposition and recomposes everything that depends on it. Make your types stable by marking them with @Immutable or @Stable, or by using value types like data classes with immutable properties:

kotlin
@Immutable
data class User(
    val id: String,
    val name: String,
    val email: String
)

@Composable
fun UserCard(user: User) {
    Text(user.name)
    Text(user.email)
}

Compose's compiler plugin can now reason about User and skip recomposing UserCard if the user parameter hasn't changed. Without the annotation, Compose plays it safe and recomposes anyway.

The second lever is remember. Use it to cache expensive computations or object allocations across recompositions:

kotlin
@Composable
fun TransactionList(transactions: List) {
    val grouped = remember(transactions) {
        transactions.groupBy { it.date }
    }
    
    LazyColumn {
        grouped.forEach { (date, items) ->
            stickyHeader {
                Text(date)
            }
            items(items) { transaction ->
                TransactionRow(transaction)
            }
        }
    }
}

Here, grouped is recomputed only when transactions changes, not on every recomposition. Without remember, you'd allocate and build a new map every frame—expensive if your transaction list is large.

Lists — Keys, contentType and lightweight items

In ride-hailing and travel apps, lists are often the bottleneck. A map with hundreds of nearby drivers, or a scrollable list of search results, can easily drop frames if you're not deliberate about performance.

Three patterns matter: stable keys, content types, and item weight.

Stable keys help Compose track which items have moved or been removed when your list updates. Use a unique, stable identifier from your data model, not the list index:

kotlin
LazyColumn {
    items(
        items = rides,
        key = { ride -> ride.id }  // stable unique ID
    ) { ride ->
        RideCard(ride)
    }
}

Without stable keys, Compose can't tell whether an item moved or a new one was inserted, and it may unnecessarily recompose or re-layout items.

Content types let Compose group similar items together and reuse composition state more efficiently:

kotlin
LazyColumn {
    items(
        items = mixed,
        key = { it.id },
        contentType = { item ->
            when (item) {
                is HeaderData -> "header"
                is DriverCard -> "driver"
                is AdBanner -> "ad"
            }
        }
    ) { item ->
        when (item) {
            is HeaderData -> HeaderRow(item)
            is DriverCard -> DriverRow(item)
            is AdBanner -> AdRow(item)
        }
    }
}

Items with the same content type are grouped and composed together, reducing unnecessary composition overhead.

Lightweight items are crucial. If each item in your LazyColumn does heavy work—network requests, complex calculations, large allocations—you're going to drop frames, especially on mid-range devices. Keep item composables simple and defer side effects to LaunchedEffect or event handlers:

kotlin
@Composable
fun DriverCard(driver: Driver) {
    // Lightweight UI only
    Card {
        Text(driver.name)
        Text(driver.rating)
        Image(
            painter = rememberAsyncImagePainter(driver.photoUrl),
            contentDescription = driver.name
        )
    }
}

Avoid doing network requests, database queries, or complex filtering inside the item composable. That work belongs upstream, in your ViewModel or data layer.

Measure — Macrobenchmark and the Android Studio profiler

You can't improve what you don't measure. The Android Studio profiler and macrobenchmark are your primary tools for identifying bottlenecks in production-like conditions.

The profiler's CPU timeline shows whether you're CPU-bound or blocked on I/O. The memory timeline reveals allocations and garbage collection pressure. The frames timeline plots frame times and identifies jank. Start by recording a short trace while scrolling a list or navigating between screens, then examine the data:

kotlin
@RunWith(AndroidJUnit4::class)
class ScrollBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun scroll() = benchmarkRule.measureRepeated(
        packageName = "com.example.myapp",
        metrics = listOf(FrameTimingMetric()),
        iterations = 10
    ) {
        startActivityAndWait()
        device.flingForward()
    }
}

Run this benchmark on a real device (not an emulator) and inspect the results. Frame times above 16.67ms on a 60Hz display or above 8.33ms on a 120Hz display indicate jank. Use the profiler to drill into the slow frames and identify the culprit—often a recomposition, a garbage collection pause, or an expensive drawing operation.

Baseline profiles and startup time metrics are your first measurement targets. Frame rate and jank come next. Memory allocations and GC pauses come after you've addressed CPU issues.

Common mistakes

  • Ignoring baseline profiles in production builds. Baseline profiles are a form of free performance. Regenerate them after major changes to your hot paths.
  • Using list indices as keys. This breaks item identity when your list updates, causing unnecessary recompositions and lost state.
  • Calling expensive functions directly in composables. Move parsing, filtering, sorting, and network calls to the data layer or ViewModel, not into your UI layer.
  • Not measuring on real devices. Emulator performance is a poor proxy for real hardware, especially on mid-range or older devices where you see the largest regression.
  • Profiling release builds without symbol information. Always profile with debug symbols available, or use a release build with deoptimization disabled for accurate flame graphs.

Key takeaways

  • Startup time wins come from baseline profiles and lazy initialization of non-critical work on background threads.
  • Recomposition overhead is cut by marking data classes as stable and using remember for expensive computations.
  • List performance depends on stable keys, content types, and keeping item composables lightweight.
  • Measure everything: use Android Studio profiler for interactive debugging and macrobenchmark for CI/CD integration.
  • Performance is a continuous practice, not a one-time fix—integrate benchmarks into your testing pipeline from the start.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles