ANR & Memory Management Explained
PERFORMANCE › Runtime
This topic bundles two related but distinct threats to a healthy app: **ANRs**, where the main thread stops responding to the user, and **memory problems**, where objects live longer than they should (leaks) or the heap simply runs out (OOM). They're related because both come from misusing the main thread and object lifetimes, but an interviewer will expect you to keep them straight rather than blur them together.
The mental model for the whole lesson: an ANR is a *timing* failure, the main thread was busy when it needed to be free. A leak is a *lifetime* failure, something kept a reference alive past the point it should have been collected. Different symptom, different fix, same root cause category: not respecting what the main thread and the garbage collector need from you.
Interviewers expect you to know the **exact ANR thresholds**, not just "the main thread was blocked":
- **5 seconds**: input or key dispatch is blocked, the classic user-perceived ANR and the one Android vitals tracks as a core metric. - **10 seconds**: a foreground BroadcastReceiver.onReceive() hasn't returned (background receivers get 60s). - **20 seconds**: a foreground Service's lifecycle callbacks (onCreate, onStartCommand, onBind) haven't completed (background services get 200s). - **~5 seconds**: you called startForegroundService() but the service never called startForeground(), this one doesn't just ANR, it crashes the app with a RemoteServiceException.
Notice the pattern: receivers and services get more room than raw input handling, because their work is expected to take a beat, but none of them are a license to block indefinitely.
What actually blocks the main thread? Slow disk or network IO done synchronously, heavy computation (parsing, image decoding), lock contention or an outright deadlock, and synchronous Binder IPC calls to another process, all of these steal time the frame or input handler needed.
The modern fix is consistent across all of them: move the work off the main thread with **coroutines** on Dispatchers.IO (blocking IO) or Dispatchers.Default (CPU-heavy work), then hop back to Dispatchers.Main to touch the UI. For work that should survive process death or doesn't need to run immediately, reach for WorkManager instead. AsyncTask did this job historically but is deprecated, don't reach for it in new code.
binding.button.setOnClickListener {
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) { fetchFromNetwork() }
textView.text = data // back on Main automatically
}
}
For a BroadcastReceiver whose work might run long, goAsync() is the tool. It hands you a PendingResult and tells the system "I'm not done yet, hold the receiver open", so you can hop to a background thread without blocking onReceive() itself:
class UploadReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val result = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
doUpload()
} finally {
result.finish()
}
}
}
}
Crucially, goAsync() doesn't remove the 10-second ceiling, it just lets the actual work happen off the main thread while onReceive() returns immediately. You still must call finish() promptly, or the system keeps treating the receiver as active.
Now the memory half of the lesson: **leaks**. The classic beginner mistake is stashing an Activity (or a View, or any Context derived from one) in a static field:
companion object {
var instance: MyActivity? = null // leaks the entire view tree
}
A static field belongs to the class, not to any instance, so it lives for the whole process. Once you assign an Activity to it, the GC can never collect that Activity, or anything it holds (its entire view hierarchy), even after onDestroy() runs and the system considers it finished. The fix is simple: don't store Activity or View references statically. If you truly need process-wide access to something Context-shaped, use applicationContext, which is safe to hold forever because it *is* meant to live that long.
A subtler version of the same bug: a **non-static inner class** like a Handler's Runnable. In Kotlin or Java, a non-static inner class silently holds an implicit reference to its outer instance. If that Runnable is still queued on a Handler after the Activity is destroyed, the queued message keeps the whole Activity alive.
The fix has two parts: hold the outer object via a WeakReference instead of an implicit strong one, and clear any pending work in onDestroy():
class MyActivity : AppCompatActivity() {
private val activityRef = WeakReference(this)
private val handler = Handler(Looper.getMainLooper())
private val runnable = Runnable { activityRef.get()?.updateUI() }
override fun onDestroy() {
super.onDestroy()
handler.___(runnable)
}
}
The same discipline applies to any listener or receiver you register, pair every registration with an unregistration, ideally in the matching lifecycle callback (onStart or onStop), or use a lifecycle-aware component that does it for you.
For cases where you genuinely want a reference that doesn't block collection, Kotlin or Java gives you two flavors, and interviewers love to ask which is which:
- **WeakReference**: cleared at the very next GC cycle once the referent is only weakly reachable. It's for "I want to know about this object while it exists, but I never want to be the reason it stays alive", the Activity-in-a-Handler pattern above. - **SoftReference**: kept around until the heap is actually under memory pressure, right before an OutOfMemoryError would occur. That makes it suited to a memory-sensitive cache, hold on to data as long as there's room, but let it go before crashing.
val weakBitmap: WeakReference<Bitmap> = WeakReference(bitmap) // gone at next GC
val softCache: SoftReference<Bitmap> = SoftReference(bitmap) // gone only under pressure
If someone asks "which reference type for a cache", the answer is almost always SoftReference (or better, a bounded LruCache), and "which reference type to avoid a leak in a callback" is WeakReference.
There's a framework-provided answer to several of the leak patterns above: ViewModel. A ViewModel is held by a ViewModelStore that survives configuration changes, so when the device rotates, the same ViewModel instance reattaches to the new Activity instead of being recreated and leaking the old one. It's only cleared, and onCleared() called, when the owning Activity or Fragment is finishing for real, not just rotating.
That matters for coroutines too. Launch work in viewModelScope instead of lifecycleScope or a raw CoroutineScope, and you get structured cancellation for free: onCleared() cancels viewModelScope automatically, so any in-flight coroutine tied to it dies with the ViewModel instead of continuing to run against a UI that's gone.
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { repository.fetch() }
_uiState.value = result
}
}
}
The other side of memory trouble is running out entirely: OutOfMemoryError. Every app has a hard per-process heap cap, and bitmaps are usually what blows past it: a 1000x1000 pixel bitmap decoded as ARGB_8888 (4 bytes per pixel) costs about 4MB, and a screen full of full-resolution photos adds up fast.
The defenses cut the byte cost directly rather than trying to GC your way out of it: downsample with inSampleSize so you decode fewer pixels in the first place, or switch to RGB_565 (2 bytes per pixel) when you don't need an alpha channel. In practice, reach for an image-loading library like Coil or Glide, they apply both automatically and manage a bounded, evictable cache for you instead of you hand-rolling BitmapFactory.Options everywhere.
onTrimMemory(level) is the callback that tells you, well before an OOM, how urgently the system wants memory back, and letting it guide what you release beats guessing. TRIM_MEMORY_UI_HIDDEN fires the moment your UI is no longer visible, the right moment to drop anything tied purely to what was on screen: bitmap caches, view caches, anything cheap to reload later. While the process is still running but memory is getting tight, you get TRIM_MEMORY_RUNNING_MODERATE, then _LOW, then _CRITICAL, each one a signal to release more aggressively than the last. If your process is idle in the background, TRIM_MEMORY_COMPLETE means you're near the front of the kill list, hang onto only what you can't afford to lose.
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
imageCache.evictAll()
}
}
Two tools catch what discipline alone misses. LeakCanary hooks into object destruction and dumps the heap the moment something that should be gone, an Activity, a Fragment, isn't, then walks the reference chain straight to the leak. The Memory Profiler in Android Studio gives you the live picture instead: allocation counts and heap size over time, plus a button to force a GC and see what survives it.
StrictMode is different, it doesn't find leaks after the fact, it flags the habits that cause them while you're still writing the code. Its ThreadPolicy watches the main thread for the IO you're not supposed to do there: disk reads, disk writes, network calls. Its VmPolicy is the leak detector: it flags leaked Closeables, unclosed SQLite cursors, and Activity instances that outlive their expected lifetime. Both report via penaltyLog() in debug builds, catching exactly the mistakes earlier chunks warned about, only automatically.
All of this helps you prevent problems, but production ANRs and low-memory kills still happen in the wild, often on a device you'll never see. Since API 30, ActivityManager.getHistoricalProcessExitReasons() gives you a way to look back: it returns a list of ApplicationExitInfo records describing why your process died on its last few runs, whether that's REASON_ANR, REASON_LOW_MEMORY, REASON_CRASH_NATIVE, and more. For an ANR specifically, the record even carries a traceInputStream with the actual ANR trace, the same kind of stack dump you'd otherwise only get from a connected debugger.
val am = getSystemService(ActivityManager::class.java)
val exits = am.getHistoricalProcessExitReasons(null, 0, 5)
for (info in exits) {
if (info.reason == ApplicationExitInfo.REASON_ANR) {
Log.w("ExitInfo", "ANR at ${info.timestamp}")
}
}
Call it early in Application.onCreate() on the next launch, it's a postmortem tool, not a live monitor.