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
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
- ANR causes — Application Not Responding is triggered when the main thread is blocked for 5+ seconds (input events), a BroadcastReceiver.onReceive() exceeds 10 seconds, or a Service.onCreate/onStartCommand() exceeds 20 seconds. Root causes: network/DB on main thread, deadlock, long synchronous computation, waiting on a locked Mutex on main.
- Detecting ANR — ANRs appear in Play Console under Android Vitals. Use StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build()) during development to catch main thread violations before they become ANRs in production. Crashlytics also captures ANR traces.
- Jank and the 16ms frame budget — At 60fps, Android has 16.67ms per frame. If a frame takes longer (measured by Choreographer), it is dropped — the previous frame is shown again, causing visible stutter. On 90Hz/120Hz displays the budget is 11ms/8ms. Jank appears as stuttery scrolling, animation lag, or touch response delays.
- Detecting jank — Android Studio Profiler (CPU trace with 'Render frames' view), GPU rendering bars in Developer Options, Perfetto traces. Key signals: long measure/layout passes, expensive onDraw() calls, overdraw (too many overlapping views), main thread work during Vsync window.
- RecyclerView jank — Common causes: heavy work in onBindViewHolder() (image decoding, string formatting), creating objects inside onDraw(), incorrect stable IDs (triggers full re-layout on DiffUtil). Fix: pre-compute in background, use Coil/Glide for async image loading, implement DiffUtil.ItemCallback correctly with areContentsTheSame().
- Memory leak sources — Static reference to Context or Activity (use ApplicationContext for singletons). Inner class holding implicit reference to enclosing Activity (use static inner class + WeakReference). Unregistered listeners/callbacks (always unregister in onDestroy or use Lifecycle-aware components). ViewModel holding Fragment reference.
- Detecting memory leaks — LeakCanary library automatically detects and reports leaks during development with a visual UI. Android Studio Profiler's Memory tab shows heap dump and object allocation. Look for Activity instances that should have been GC'd (filtered by class name in heap dump).
- Cold start vs warm start vs hot start — Cold: process not running — full initialization (Application.onCreate, Activity.onCreate, first frame). Warm: process running but Activity destroyed — Activity re-created, skips Application init. Hot: Activity in back stack — just resumed, fastest. Cold start is what users notice after install or force-stop.
- Cold start optimization — Move heavy init (SDKs, DB prepopulation) out of Application.onCreate() — use lazy initialization or startup tasks. Use App Startup library (Jetpack) to control and sequence initialization with dependency ordering. Show a splash screen using SplashScreen API (not a separate Activity) to hide initialization time.
- Baseline Profiles — A Baseline Profile is a list of critical code paths (classes, methods) that the Android Runtime (ART) pre-compiles from bytecode to native machine code on app install or update. This eliminates JIT compilation delays on the first run of those paths, significantly reducing cold start time and initial jank.
- Avoid overdraw — Overdraw is drawing the same pixel multiple times per frame. Use Debug > Show GPU Overdraw in Developer Options. Remove unnecessary backgrounds (child views with same background as parent). Avoid translucent backgrounds in lists. Clip drawing in custom views with canvas.clipRect().
- Lazy loading and pagination — Never load all data upfront. Use Paging 3 for paginated lists — loads pages on demand, integrates with Room and Retrofit. Use LazyColumn/LazyRow in Compose (items are composed only when visible). In Tixio SaaS, switching task lists from full load to Paging 3 reduced initial load time by 70%.
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. StrictMode.setThreadPolicy() in Application.onCreate() — wrapping in BuildConfig.DEBUG ensures these developer-only penalties never run in production builds.
- 2. penaltyLog() writes violations to Logcat — use penaltyDeath() in CI to make violations cause test failures, catching regressions before code review.
- 3. ctx.applicationContext in GoodSingleton — ApplicationContext lives as long as the process, safe for singletons. Activity context is tied to the Activity lifecycle.
- 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. App Startup Initializer.create() runs when the ContentProvider is initialized — before Application.onCreate() completes, in topological dependency order.
- 6. dependencies() returning listOf(FirebaseInitializer::class.java) ensures FirebaseApp is initialized before AnalyticsInitializer.create() is called.
- 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. startActivityAndWait() launches the app and waits for the first fully drawn frame — this is the cold start path that gets AOT compiled.
- 9. areItemsTheSame() uses only the stable ID — this determines if an item moved position (same item, new position triggers move animation).
- 10. areContentsTheSame() uses data class equals() — this determines if the item's visual content changed (triggers change animation or rebind).
- 11. In StrictMode, detectDiskReads/Writes catches SharedPreferences.commit() and Room queries on the main thread — force them to background threads.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- ANR Overview — Android Developers (Android Docs)
- App Startup Library (Android Docs)
- Baseline Profiles Guide (Android Docs)
- LeakCanary — Memory Leak Detection (Square / LeakCanary)
- Android Vitals — Play Console (Android Docs)