Skip to content

Recommended Reading

Android App Performance: Best Practices to Build Fast, Efficient Mobile Apps

18 Minutes

Android App Performance: Best Practices to Build Fast, Efficient Mobile Apps

Fix Bugs Faster! Log Collection Made Easy

Get started

Did you know more than half of users will bail if your app doesn’t load in under three seconds? That’s not a fun stat. But it’s real, and it shows up fast, especially in high-traffic moments.

Take an e-commerce app during a big sale. One delay during checkout, one stutter when loading the cart, and users are gone. That team watched retention nosedive because mobile app performance didn’t hold up under pressure. The problem wasn’t the features. It was the lag.

Optimizing Android mobile app performance isn’t just a user satisfaction issue. It affects discoverability too. Google Play’s ranking algorithms factor in everything from crash rate to responsiveness. The smoother your Android app runs, the better your chances of getting surfaced in search results. The inverse is brutal: negative reviews, rising uninstall rates, and users who don’t give your next update a second chance.

And then there’s the hardware. Mobile devices aren’t beefy workstations. You’re working with tight memory, limited CPU capacity, and a battery that users expect to last all day. That puts pressure on every decision from layout to load time.

This guide walks through the techniques, tools and patterns that help Android developers deliver apps that feel fast, stay fast, and scale without breaking under load. We’ll cover what slows mobile apps down, how to catch it early, and how to fix it in a way that sticks. Because app performance isn’t a polish step. It’s part of the foundation of app development.

Why optimizing Android app performance is essential

User experience

If your mobile application is not smooth, users will not use it. That is the simple truth. Fast loading and instant responses are default expectations from today’s users. People don’t wait. They quit. And most of them don’t come back. Performance is usability. Period.

//Example: Optimize RecyclerView for Smooth Scrolling
// Adapter ViewHolder Implementation
class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val titleText: TextView = itemView.findViewById(R.id.titleText)
}

// Avoid repeated inflation in onBindViewHolder
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    holder.titleText.text = dataList[position].title
}

Battery & Memory impact

Apps don’t run in a vacuum. They’re sharing space with everything else on the device: messaging apps, music, background sync, low battery warnings. If your app chews through memory or drains power in the background, users will notice and, more likely than not, uninstall it.

Good apps respect the device. That means smarter background work, tighter memory allocation, and dropping anything that’s not essential. Lightweight apps don’t just feel faster, they work better on more devices, especially low-end ones.

//Example: Use WorkManager Instead of a Long-Running Background Service
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiresCharging(true)
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    ).build()

WorkManager.getInstance(context).enqueue(workRequest)
//Example: Prevent Memory Leak with WeakReference
class MyListener(context: Context) {
    private val contextRef = WeakReference(context)

    fun doSomething() {
        contextRef.get()?.let {
            // Safe to use context
        }
    }
}

Store rankings & crash metrics

High crash rates, frequent ANRs and slow startup times will quietly drag your ranking down. And if things get bad enough, visibility drops off a cliff. The upside? Stable, fast apps get rewarded. They get surfaced, featured and shown to more users. So every time you cut the cold start time or fix a UI thread hang, you’re not just improving the app, you’re boosting discoverability.

//Crash Free User Code Example with Try & Catch Logging
try {
    riskyOperation()
} catch (e: Exception) {
    Log.e("AppError", "Something went wrong", e)
    FirebaseCrashlytics.getInstance().recordException(e)
}

Brand reputation

When an app performs well, users trust the team behind it. When it doesn’t, they blame the brand, even if the bug was buried in some third-party SDK. Poor app performance is not just a technical problem; it hurts brand reputation. But when an app is smooth, responsive and consistent, it feels premium and that can build customer loyalty.

//Tip: Display a fast, informative splash screen instead of delayed load
<!-- themes.xml -->
<style name="AppTheme.Launcher">
    <item name="android:windowBackground">@drawable/splash_screen</item>
</style>
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    // Initialize heavy logic AFTER UI load
}

Common performance issues

Before you can fix performance, you have to know where it breaks. These are the usual suspects that can slow an Android app down, frustrate users and quietly wreck your ratings:

Slow UI rendering

What it looks like: Choppy scrolls, sluggish transitions, skipped frames; anything that feels visually off or lags behind your touch.

What causes it: Most of the time, it’s work being done on the main thread that has no business being there. Layouts that are too deep, views that redraw too often, and rendering logic that’s too heavy to hit the 16ms frame target needed for smooth 60fps.

How to catch it: Fire up Profile GPU Rendering or use Layout Inspector in Android Studio. Look for frame spikes, overdraw and excessive remeasure calls.

How to fix it:

  • Simplify your view hierarchy. Don’t nest just to align.
  • Replace NestedScrollView unless you really need it.
  • Swap out stacked layouts for ConstraintLayout wherever possible. It’s leaner and flattens complexity. Avoid deeply nested LinearLayout or RelativeLayout.

If it stutters, your users feel it. And they won’t wait around for a fix.

//Example: Efficient ConstraintLayout
<androidx.constraintlayout.widget.ConstraintLayoutandroid:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextViewandroid:id="@+id/label"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="Performance Matters"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Memory leaks

What you’ll see: The app gets slower the longer it runs. Memory usage climbs. Eventually, it crashes with an OutOfMemoryError.

What’s behind it: Something’s holding onto memory that should’ve been released.

How to catch it: We need to use LeakCanary to watch for leaks in real time. It’ll flag retained objects and point to where the leak started. No guesswork required.

//Example: Avoiding static context leak
// BAD: Leaks context
object AppUtils {
    var context: Context? = null
}

// GOOD: Use application context or avoid storing it
class MyHelper(context: Context) {
    private val appContext = context.applicationContext
}

Battery drain

What users notice: Battery drops fast, even when your app’s supposed to be idle.

What’s usually causing it: Too many background tasks, aggressive location polling, or wake locks that never let the device sleep.

How to track it: Start with the system Battery Usage screen. Then dig into Energy Profiler in Android Studio to catch patterns over time.

How to fix it:

  • Offload background work to WorkManager so it respects system constraints.
  • Don’t request location updates every few seconds, use FusedLocationProviderClient with smarter intervals.
  • Make sure your app behaves under Doze Mode and respects App Standby Buckets. The system will penalize apps that don’t.
//Example: Smarter location tracking
val locationRequest = LocationRequest.create().apply {
    interval = 10_000
    fastestInterval = 5_000
    priority = LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY
}

ANRs (App Not Responding)

What happens: The app locks up for more than five seconds. The system shows a “Wait” or “Close” dialog.

Why it happens: It mainly happens when heavy processes are blocking the main thread, like file access, network calls or slow JSON parsing.

How to catch it: Check Android Vitals in the Play Console for reported ANRs. For deeper traces, use Systrace or Traceview to pinpoint main thread blocks.

How to fix it:

  • Push expensive work off the main thread.
  • Use coroutines, HandlerThread, or thread pools like Executors.
  • Keep your main thread lean. It should paint UI, not process data.
//Example: Move task off Main Thread with Coroutine
lifecycleScope.launch {
    val result = withContext(Dispatchers.IO) {
        // Simulate file/database/network I/O
        heavyFunction()
    }
    updateUI(result)
}

Inefficient networking

What users see: Slow screens, high data usage, or taps that feel like they go nowhere.

What’s behind it: Too many API calls, hitting endpoints more often than needed, or skipping caching entirely. Bonus points if it’s all happening on the main thread.

How to check: Use the Network tab in Android Profiler to watch traffic in real time. For deeper inspection, run it through Charles Proxy and look at frequency, size and headers.

To fix this we need to use Retrofit + OkHttp, and make sure response caching is enabled and turn on GZIP compression for large payloads. Batch non-urgent calls, or delay them until the UI is idle.

//Example: OkHttp Caching Configuration
val cacheSize = 10 * 1024 * 1024 // 10 MB
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)

val client = OkHttpClient.Builder()
    .cache(cache)
    .addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .header("Cache-Control", "public, max-age=60")
            .build()
        chain.proceed(request)
    }
    .build()

Performance optimization techniques

If your app feels slow, this isn’t usually caused by a single big issue. It’s often a series of small ones hiding in the UI, network, memory usage, or how you handle threads. This section covers tactical fixes across each layer, backed by practices that actually scale in production.

1. UI & Rendering

Use ConstraintLayout properly

Don’t stack layouts just to line things up. That’s what ConstraintLayout is for. It flattens your hierarchy, cuts down render time and avoids nested LinearLayout headaches.

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/avatar"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:src="@drawable/avatar"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/username"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="User Name"
        app:layout_constraintStart_toEndOf="@id/avatar"
        app:layout_constraintTop_toTopOf="@id/avatar"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Cut view nesting, avoid overdraw

Deep view hierarchies slow rendering and cause overdraw. Use Debug GPU Overdraw in developer options to see where your layers are stacking.

  • Don’t give both parent and child views a background.
  • Replace stacked layouts with a single ConstraintLayout.
  • Reuse drawables where you can.

RecyclerView done right

If your scroll feels janky, chances are your ViewHolder isn’t tight. Keep view references cached, and don’t rebind what hasn’t changed.

//Optimized ViewHolder Pattern
class UserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val nameText: TextView = itemView.findViewById(R.id.nameText)
}

override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
    val user = userList[position]
    holder.nameText.text = user.name
}

Animations: use MotionLayout

MotionLayout gives you GPU-friendly transitions that stay smooth without eating CPU cycles. Define the motion in XML and let the engine handle it.

//Motion Scene Example
<MotionScene xmlns:android="<http://schemas.android.com/apk/res/android>"
    xmlns:motion="<http://schemas.android.com/apk/res-auto>">
    <Transitionmotion:constraintSetStart="@id/start"
        motion:constraintSetEnd="@id/end"
        motion:duration="500"/>
</MotionScene>

It’s declarative, reusable and easier to debug than hand-rolled animations.

For Fine-Grained frame control: Choreographer and RenderThread

When you need to sync rendering with the display frame-by-frame, like for custom drawing or game loops. Android gives you low-level hooks with Choreographer and RenderThread.

Use them only when needed. They’re powerful but low-level. Most UI devs won’t touch this unless they’re animating directly on canvas or doing custom graphics work.

//Choreographer Example
Choreographer.getInstance().postFrameCallback { frameTimeNanos ->
    // Custom UI update logic synchronized with frame rendering
}

2. Memory management

Catch leaks with LeakCanary

If you’re not running LeakCanary in debug builds, you’re flying blind. It plugs into your app and watches for objects that should’ve been garbage collected but aren’t.

//Setup:
dependencies {
    debugImplementation "com.squareup.leakcanary:leakcanary-android:2.12"
}

Don’t leak your context

A classic mistake is to non-static inner classes that quietly hang onto an Activity or View. That reference keeps your entire UI alive long after it’s supposed to be gone.

//Bad:
inner class MyAsyncTask : AsyncTask<Void, Void, Void>() {
    // this leaks the activity
}
//Fixed:
private class MyAsyncTask(activity: MyActivity) : AsyncTask<Void, Void, Void>() {
    private val activityRef = WeakReference(activity)
}

Bitmaps: resize, reuse, or regret it

Large images are memory bombs. Always downsample bitmaps before loading them into memory.

val options = BitmapFactory.Options().apply {
    inSampleSize = 2
}
val bitmap = BitmapFactory.decodeResource(resources, R.drawable.large_image, options)

Watch your listeners

Long-lived objects with strong references to views or activities = slow memory leaks. Wrap listeners in WeakReference if they’re not tightly scoped.

val listenerRef = WeakReference(listener)
listenerRef.get()?.onEentTriggered()

Battery optimization

Replace services with WorkManager

WorkManager is aware of system state. It runs only when conditions are right, no more chewing through battery just because a task was scheduled.

val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiresCharging(true)
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    ).build()

WorkManager.getInstance(context).enqueue(workRequest)

Batch jobs, don’t scatter them

When you schedule work, don’t create chaos. Use JobScheduler or WorkManager to group background tasks and let the system batch them efficiently.

val jobInfo = JobInfo.Builder(123, ComponentName(this, MyJobService::class.java))
    .setRequiresCharging(true)
    .setPeriodic(15 * 60 * 1000)
    .build()

Smarter location = Less drain

Always prefer FusedLocationProviderClient. It’s more battery-aware than legacy location APIs and balances accuracy with power.

val locationRequest = LocationRequest.create().apply {
    interval = 60000
    priority = LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY
}

Don’t fight doze mode

Trying to bypass Doze will backfire. Only use setExactAndAllowWhileIdle() if absolutely necessary.

val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.setExactAndAllowWhileIdle(
    AlarmManager.RTC_WAKEUP,
    triggerTime,
    pendingIntent
)

Networking

Retrofit + OkHttp = Clean, fast API calls

Retrofit simplifies API calls.

val retrofit = Retrofit.Builder()
    .baseUrl("<https://api.example.com/>")
    .addConverterFactory(GsonConverterFactory.create())
    .client(OkHttpClient.Builder().build())
    .build()

Cache responses to cut load time

We can use the OkHttp built-in caching feature and set proper HTTP headers on the server.

val cacheSize = 10 * 1024 * 1024
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)

val client = OkHttpClient.Builder()
    .cache(cache)
    .build()

Use GZIP, always

Smaller payloads = faster requests = happier users.

.addInterceptor { chain ->
    val request = chain.request().newBuilder()
        .header("Accept-Encoding", "gzip")
        .build()
    chain.proceed(request)
}

Defer the boring stuff

Wait until the UI is idle or delay it a few seconds.

CoroutineScope(Dispatchers.IO).launch {
    delay(5000)
    sendAnalytics()
}

Multithreading

Get work off the Main thread

Any I/O or heavy lifting belongs off the UI thread.

CoroutineScope(Dispatchers.IO).launch {
    val result = loadHeavyData()
    withContext(Dispatchers.Main) {
        updateUI(result)
    }
}

Coroutines for clean async

Kotlin Coroutines keep async readable and lightweight. Perfect for network and disk I/O.

suspend fun fetchData(): String {
    return withContext(Dispatchers.IO) {
        apiService.getData()
    }
}

RxJava for stream heavy apps

If you’re building reactive UIsthink chat apps, live search. RxJava still works.

apiService.getUsers()
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe { users -> showUsers(users) }

HandlerThread and executors

Just be sure to clean up when you’re done.

val handlerThread = HandlerThread("MyHandlerThread").apply { start() }
val handler = Handler(handlerThread.looper)
handler.post { performTask() }

Tools you should use

Android Profiler

Where it shines: CPU spikes, memory leaks, network throughput. All in one timeline.

  • Launch your app from Android Studio
  • Open the Profiler tab
  • Choose a device and inspect memory, CPU and network in real time

Use it to spot heavy lifecycle methods, allocations on scroll, or random spikes in memory usage.

LeakCanary

What it does: Watches your app for memory leaks in debug mode. Notifies you instantly, traces included.

dependencies {
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
}
//No config. No extra work. Just insight before production hits.

Firebase app performance monitoring

Why it matters: Captures the real-world data from users to get the device-specific latency, startup time and rendering issues.

dependencies {
    implementation 'com.google.firebase:firebase-perf-ktx'
}
val trace = Firebase.performance.newTrace("load_home_screen")
trace.start()
// run code
trace.stop()

View results by app version, country or device model. Invaluable after launch.

Systrace

Use it when: Something feels wrong but doesn’t show up in your logs. Like dropped frames or input lag. Run it via Android Studio or straight from adb:

adb shell atrace -t 10 gfx view sched freq

Systrace shows you system-level timing that other tools can’t.

Flipper

What it gives you: Real-time inspection of your app’s network calls, database, layout, preferences and more. For live debugging during dev and QA, Flipper is gold.

debugImplementation 'com.facebook.flipper:flipper:0.203.0'
debugImplementation 'com.facebook.soloader:soloader:0.10.1'
if (BuildConfig.DEBUG) {
    SoLoader.init(this, false)
    FlipperClient.getInstance(this).apply {
        addPlugin(InspectorFlipperPlugin(this@App))
        addPlugin(NetworkFlipperPlugin())
        start()
    }
}

Build and code optimization

Performance is not only about what happens when the application is running. It starts with the code structure and how we compile the project. If your app is slow to build, bloated at install, or easy to reverse-engineer, no runtime tweaks will save you. Solid build hygiene gives you speed, security and maintainability from the ground up.

Shrink and Obfuscate with R8

R8 strips unused code, obfuscates method names and optimizes bytecode. It’s the default shrinker with the Android Gradle plugin now – and it does what ProGuard used to do, only better.

buildTypes {
    release {
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}

Break it up: Modular architecture

Splitting your app into modules speeds up builds and makes large teams more productive. Each feature, service or shared layer should live in its own module. Modular builds let Gradle skip what hasn’t changed. CI builds get faster. Local dev builds stop dragging.

Example setup:

  • :app → main launcher module
  • :feature-login, :feature-cart, :feature-feed
  • :core-network, :core-ui, :core-database
dependencies {
    implementation project(":core-network")
    implementation project(":feature-login")
}

Testing performance

If you don’t test for performance, you’re just hoping nothing breaks. That’s not a strategy. Use automated benchmarks and targeted profiling, and run them as part of CI to catch slowdowns before your users do.

Espresso for UI flow testing

Espresso handles UI automation without flaky timing issues. It’s built into the Android testing stack and plays well with CI.

@Test
fun testUserLogin() {
    onView(withId(R.id.username)).perform(typeText("admin"))
    onView(withId(R.id.password)).perform(typeText("password"))
    onView(withId(R.id.login_button)).perform(click())
    onView(withId(R.id.welcome_message)).check(matches(isDisplayed()))
}

Macrobenchmark for startup and frame timing

Jetpack’s Macrobenchmark library gives you reliable metrics like cold start time, frame jank and power usage, all on real devices.

@get:Rule
val benchmarkRule = MacrobenchmarkRule()

@Test
fun startupBenchmark() = benchmarkRule.measureRepeated(
    packageName = "com.example.myapp",
    metrics = listOf(StartupTimingMetric()),
    iterations = 5,
    setupBlock = { pressHome() }
) {
    startActivityAndWait()
}

Continuous Integration

It’s essential to use the CI tools like GitHub Actions, Jenkins or Bitrise. These tools will run your performance tests every time code is pushed.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Run Unit Tests
        run: ./gradlew test
      - name: Run UI Tests
        run: ./gradlew connectedAndroidTest

Real-World optimization: ShopFast case study

ShopFast started strong but got crushed by performance issues:

  • 6.2s cold start time
  • 250MB memory use on mid-tier phones
  • 62MB APK
  • 12% per hour idle battery drain
  • Frame drops when scrolling product lists

Ratings tanked. Users churned. The team had to fix it, fast.

What they did

Cold start fix: Moved heavy startup logic post-onResume() and deferred API calls with placeholders.

→ Startup dropped to 3.1s

APK shrinking: Enabled R8, removed unused libraries, converted images to vector drawables.

shrinkResources true
minifyEnabled true

→ APK size dropped from 62MB to 38MB

Battery optimization: Killed periodic services. Switched to WorkManager with network and charging constraints.

val workRequest = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
    .setConstraints(
        Constraints.Builder()
            .setRequiresCharging(true)
            .setRequiredNetworkType(NetworkType.UNMETERED)
            .build()
    ).build()

→ Idle battery drain fell 70%

UI jank fix: Refactored RecyclerView, flattened layouts, added proper ViewHolder caching.

→ Scrolling hit 60fps, even on budget phones

Memory leak cleanup: Replaced inner classes, used WeakReference, ran LeakCanary on every build.

→ Memory use down 30MB on average

To Sum up

Creating a great Android app today means more than packing in features; it demands a performance-first approach. From install to daily use, speed and efficiency define the user experience. If it stutters, crashes, or drains battery, users leave. Reviews drop. Uninstalls spike.

This guide laid out the critical areas where performance makes or breaks your app . Smooth UI, low memory usage, smart battery handling, stable networking and clean multithreading. We discussed how tools like ConstraintLayout, LeakCanary, WorkManager, Retrofit and Coroutines aren’t optional – they form a crucial arsenal in your bid to keep apps lean, responsive and scalable.

We also covered the stack of tools that help catch issues before users do. You need to use Android Profiler, Firebase Performance Monitoring, Macrobenchmark, Systrace and Flipper. Combine these with solid build practices like R8, modular architecture, lazy dependency loading and you reduce cold starts, cut APK size, and ship faster builds that run cleaner. Performance wins translate into faster startups, lighter installs, better reviews and less churn. That wasn’t magic, it was process.

We need to treat performance as a product feature for which we need to profile often and test constantly. Refactor aggressively. We should optimize with purpose.

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Sachin Siwal

Sachin is an accomplished iOS developer and seasoned technical manager, combining 12 years of mobile app expertise with a knack for leading and delivering innovative solutions. You can contact him on Linkedin

Join thousands of developers
and start fixing bugs faster than ever.