Lesson 36 of 83 advanced

Android Performance: ANR, Jank, Memory Leaks & Startup Time

Diagnose and eliminate the performance issues that kill user retention

Open interactive version (quiz + challenge)

Real-world analogy

ANR is like a waiter who disappears for 5 minutes — the restaurant (Android OS) eventually puts up a sign 'Is this place broken?' and customers (users) leave. Jank is like a film with missing frames — the brain notices the stutter even if most frames are smooth. A memory leak is like a conference room that's never released after the meeting ends — eventually there are no rooms left and the building shuts down. Cold start optimization is like pre-staging the restaurant before opening — so the first customer gets served instantly.

What is it?

Android performance encompasses: responsiveness (ANR prevention), smoothness (jank elimination), memory health (leak prevention), and startup speed (cold/warm start optimization). These four pillars directly impact app store ratings, user retention, and Play Store featuring in Android Vitals. Senior engineers are expected to diagnose all four using profiling tools and implement systematic fixes.

Real-world relevance

In Tixio SaaS, cold start time was 3.2 seconds. After: moving Firebase init to App Startup library (lazy), pre-computing the navigation state in a background coroutine, and generating a Baseline Profile for the login and home flows — cold start dropped to 1.1 seconds. LeakCanary caught a ViewModel holding a Fragment reference (passed as lambda) causing Activity leaks during screen rotation.

Key points

Code example

// 1. StrictMode — catch violations in development
class MyApplication : Application() {
    override fun onCreate() {
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()  // Any network on main thread = violation
                    .penaltyLog()     // Log to Logcat (use penaltyDeath() for hard fail)
                    .build()
            )
        }
        super.onCreate()
    }
}

// 2. Memory leak — common patterns and fixes
// BAD: Static reference holds Activity context
object BadSingleton {
    var context: Context? = null  // LEAK — Activity never GC'd
}

// GOOD: Use ApplicationContext
object GoodSingleton {
    lateinit var appContext: Context
    fun init(ctx: Context) { appContext = ctx.applicationContext }
}

// BAD: Inner class holds implicit Activity reference
class MainActivity : AppCompatActivity() {
    inner class BadHandler : Handler(Looper.getMainLooper()) {  // LEAK
        override fun handleMessage(msg: Message) { /* uses MainActivity.this implicitly */ }
    }
}

// GOOD: Static inner class with WeakReference
class MainActivity : AppCompatActivity() {
    class SafeHandler(activity: MainActivity) : Handler(Looper.getMainLooper()) {
        private val ref = WeakReference(activity)
        override fun handleMessage(msg: Message) {
            ref.get()?.onHandleMessage(msg)
        }
    }
}

// 3. App Startup — ordered lazy initialization
class FirebaseInitializer : Initializer<FirebaseApp> {
    override fun create(context: Context): FirebaseApp {
        return FirebaseApp.initializeApp(context)!!
    }
    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

class AnalyticsInitializer : Initializer<FirebaseAnalytics> {
    override fun create(context: Context): FirebaseAnalytics {
        return FirebaseAnalytics.getInstance(context)
    }
    override fun dependencies() = listOf(FirebaseInitializer::class.java) // depends on Firebase
}

// AndroidManifest.xml entry for App Startup:
// <provider android:name="androidx.startup.InitializationProvider" ... >
//     <meta-data android:name="com.example.AnalyticsInitializer" android:value="androidx.startup"/>
// </provider>

// 4. Baseline Profile generation (macrobenchmark module)
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect("com.example.tixio") {
        // Critical user journeys — these paths get AOT compiled
        pressHome()
        startActivityAndWait()  // cold start — main activity
        device.findObject(By.text("Login")).click()
        waitForIdle()
    }
}

// 5. DiffUtil for RecyclerView — prevent full re-layout
class TaskDiffCallback : DiffUtil.ItemCallback<Task>() {
    override fun areItemsTheSame(old: Task, new: Task) = old.id == new.id
    override fun areContentsTheSame(old: Task, new: Task) = old == new // data class equals
}

Line-by-line walkthrough

  1. 1. StrictMode.setThreadPolicy() in Application.onCreate() — wrapping in BuildConfig.DEBUG ensures these developer-only penalties never run in production builds.
  2. 2. penaltyLog() writes violations to Logcat — use penaltyDeath() in CI to make violations cause test failures, catching regressions before code review.
  3. 3. ctx.applicationContext in GoodSingleton — ApplicationContext lives as long as the process, safe for singletons. Activity context is tied to the Activity lifecycle.
  4. 4. WeakReference(activity) in SafeHandler — allows the Activity to be garbage collected even if the Handler message is still in the queue. Always null-check ref.get().
  5. 5. App Startup Initializer.create() runs when the ContentProvider is initialized — before Application.onCreate() completes, in topological dependency order.
  6. 6. dependencies() returning listOf(FirebaseInitializer::class.java) ensures FirebaseApp is initialized before AnalyticsInitializer.create() is called.
  7. 7. BaselineProfileRule.collect() runs the provided block on a real device, records which classes and methods are executed, and outputs a baseline-prof.txt file.
  8. 8. startActivityAndWait() launches the app and waits for the first fully drawn frame — this is the cold start path that gets AOT compiled.
  9. 9. areItemsTheSame() uses only the stable ID — this determines if an item moved position (same item, new position triggers move animation).
  10. 10. areContentsTheSame() uses data class equals() — this determines if the item's visual content changed (triggers change animation or rebind).
  11. 11. In StrictMode, detectDiskReads/Writes catches SharedPreferences.commit() and Room queries on the main thread — force them to background threads.
  12. 12. Baseline Profiles are packaged in the APK/AAB and applied by ART during installation — no runtime overhead, purely an install-time optimization.

Spot the bug

class MainActivity : AppCompatActivity() {
    // Bug 1 — inner class memory leak
    private val updateHandler = object : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            updateUI(msg.obj as String)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Bug 2 — heavy work on main thread
        val db = Room.databaseBuilder(this, AppDatabase::class.java, "app.db")
            .build()
        val user = db.userDao().getUser()  // synchronous query!

        viewModel.data.observe(this) { items ->  // Bug 3 — leak if not lifecycle-aware
            recyclerView.adapter = MyAdapter(items)  // Bug 4 — new adapter on every update
        }
    }
}

class MyAdapter(private val items: List<Item>) :
    RecyclerView.Adapter<MyAdapter.ViewHolder>() {

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = items[position]
        holder.icon.setImageBitmap(
            BitmapFactory.decodeResource(holder.itemView.resources, item.iconRes)  // Bug 5
        )
        holder.title.text = item.title
    }
}
Need a hint?
Check Handler inner class leak, synchronous Room query on main thread, LiveData observer lifecycle, adapter replacement vs update, and bitmap decoding in bind.
Show answer
Bug 1: Anonymous Handler object holds an implicit reference to MainActivity — if a message is queued when the Activity is destroyed (e.g., during rotation), the Handler keeps the Activity in memory (leak). Fix: use a static inner class with WeakReference<MainActivity>, or use lifecycleScope to post updates via coroutines which are automatically cancelled on destroy. Bug 2: db.userDao().getUser() is a synchronous Room database query called on the main thread — this blocks the UI thread and will trigger StrictMode violations and potentially ANR if the query is slow. Fix: use a suspend function in a CoroutineWorker or call within lifecycleScope.launch { withContext(Dispatchers.IO) { ... } }, or use LiveData/Flow from Room which delivers results on a background thread automatically. Bug 3: viewModel.data.observe(this, ...) with 'this' as the LifecycleOwner is actually correct in an Activity. However if this pattern were used in a Fragment, using 'this' instead of 'viewLifecycleOwner' would leak the observer across Fragment view recreations. Noting: in the Activity context this is fine, but the adapter replacement (Bug 4) is the critical issue. Bug 4: recyclerView.adapter = MyAdapter(items) creates and assigns a brand new adapter on every data update — this throws away all RecyclerView state (scroll position, item animations, ViewHolder recycling pool) and triggers a full layout pass. Fix: create the adapter once in onCreate(), use ListAdapter (which extends RecyclerView.Adapter and has built-in DiffUtil), and call adapter.submitList(items) on updates. Bug 5: BitmapFactory.decodeResource() in onBindViewHolder() performs synchronous bitmap decoding on the main thread for every visible item — this causes severe jank in lists (each scroll step decodes bitmaps on the UI thread). Fix: use Coil (imageView.load(item.iconRes)) or Glide which perform decoding on a background thread, cache decoded bitmaps, and cancel loads when ViewHolders are recycled.

Explain like I'm 5

ANR is when your app freezes like a spinning wheel — the phone waits 5 seconds then asks 'Is this broken?'. Jank is like a flipbook that skips pages — the animation stutters instead of flowing smoothly. A memory leak is like leaving all the lights on in every room you visit — eventually the electricity runs out. Cold start is how fast your app opens from completely closed — like starting a car from cold vs one that's already warm.

Fun fact

The Android Choreographer fires a Vsync signal every 16.67ms. If your View's onDraw() method allocates any object (even a Paint or Rect), the garbage collector may pause the rendering thread mid-frame — causing a dropped frame. This is why Android Lint warns about object allocation in onDraw(): it's not just style, it's a real jank risk at 60fps.

Hands-on challenge

Performance audit a hypothetical app: (1) Add StrictMode with detectAll() + penaltyLog() in debug builds only, gated by BuildConfig.DEBUG; (2) Find and fix three memory leak patterns in provided code: static Context reference, inner class Runnable posted to a Handler, unregistered LiveData observer in a non-Lifecycle-aware class; (3) Move all SDK initialization from Application.onCreate() to App Startup Initializer classes with correct dependency ordering; (4) Write a Baseline Profile generator using MacroBenchmark that covers the cold start, login, and main feed loading user journeys; (5) Implement DiffUtil.ItemCallback for a Task list with proper areItemsTheSame (ID comparison) and areContentsTheSame (data class equality).

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery