ANR & Memory Management Explained

PERFORMANCE › Runtime

ANR and memory questions are really two different failure categories that share one root cause: not respecting what the main thread and the garbage collector need from you. An ANR is a timing failure, the main thread was busy with something when the system needed it free to handle input or a lifecycle callback. A leak is a lifetime failure, some reference kept an object reachable long after nothing should still need it, and eventually the heap fills up and you get an out of memory crash. Keep those two categories straight in an interview: a slow database query on the main thread causes an ANR, a static reference to a destroyed activity causes a leak, and they call for completely different fixes. What actually blocks the main thread long enough to trigger an ANR is a short list: slow disk or network I/O done synchronously, heavy computation like parsing or image decoding, lock contention or an outright deadlock, and synchronous binder calls that block waiting on another process to respond.

Android enforces exact timeouts for how long different components can occupy the main thread, and interviewers expect you to know the numbers, not just wave at 'too long'. Input or key dispatch is the strictest: if the main thread doesn't process a touch or key event within five seconds, the system raises the classic user-perceived ANR, the one Android vitals tracks as its core responsiveness metric. A foreground BroadcastReceiver gets more room because its onReceive callback is expected to do a little more than repaint a frame: ten seconds in the foreground, sixty seconds if the receiver is running in the background. A foreground Service's lifecycle callbacks, onCreate, onStartCommand, onBind, get even longer: twenty seconds in the foreground, two hundred seconds in the background. None of these numbers are a license to block indefinitely, they're just how long the system waits before deciding your app has hung.

When a foreground BroadcastReceiver genuinely needs more than an instant, the fix isn't to hope the ten second window is generous, it's to get off onReceive as fast as possible using goAsync. Calling goAsync hands you back a PendingResult and tells the system 'I'm not finished, keep this receiver alive a little longer' while onReceive itself returns immediately. You then do the real work on a background dispatcher and call finish on that PendingResult when it's done. This doesn't remove the timeout, it just moves the blocking work off the thread that the timeout actually watches, and it still needs to wrap up promptly or the system keeps treating the receiver as active. Forgetting to call finish is a common bug: the receiver looks done from the caller's perspective but the system still considers it in flight.

class UploadReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val result = goAsync()
        CoroutineScope(Dispatchers.IO).launch {
            try {
                doUpload()
            } finally {
                result.finish()
            }
        }
    }
}

Services get the most generous timeout of the three ANR-relevant components, but it's still a hard limit. A foreground Service's lifecycle callbacks, onCreate, onStartCommand, and onBind, must complete within twenty seconds or the system raises an ANR; a background service gets two hundred seconds. The reasoning is the same as everywhere else in this lesson: these callbacks are expected to do real setup work, maybe touching storage or starting a notification, but they still can't block forever. If a service needs longer than that to actually do its job, the setup callbacks should kick off the work asynchronously and return, not perform the work inline.

There's a second, separate deadline that trips people up because it isn't an ANR at all: when you call startForegroundService, the Service you started has roughly five seconds to actually call startForeground with a notification. Miss that window and the system doesn't wait patiently or quietly demote the service, it crashes your app outright with a RemoteServiceException. This is a common production bug in apps that do meaningful setup, like fetching data or acquiring a resource, before showing the required notification: that setup has to happen after startForeground is called, or fast enough to fit inside the five second window before it.

class MyForegroundService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Running")
            .setSmallIcon(R.drawable.ic_notification)
            .build()
        startForeground(NOTIFICATION_ID, notification)
        return START_NOT_STICKY
    }
}

The fix for every main-thread-blocking scenario in this lesson is the same pattern: move the work off the main thread, then hop back only to touch the UI. Kotlin coroutines are the modern tool: launch on Dispatchers.IO for blocking I/O like network or disk, or Dispatchers.Default for CPU-heavy work like parsing, then switch back to the main dispatcher to update views. For work that should survive process death or doesn't need to run immediately, WorkManager is the better fit than a raw coroutine. AsyncTask used to be the answer to this problem but is deprecated, reaching for it in new code is itself an interview red flag. A common wrong answer is wrapping the slow call in runOnUiThread, that call still posts its block to the main thread, it does nothing to move the work off it.

binding.button.setOnClickListener {
    lifecycleScope.launch {
        val data = withContext(Dispatchers.IO) { fetchFromNetwork() }
        textView.text = data
    }
}

The classic beginner leak is stashing an Activity, or a View, or any Context derived from one, in a static field. A static field belongs to the class itself, not to any instance, so it lives for as long as the process does. The moment you assign an Activity to it, the garbage collector can never reclaim that Activity, or anything it holds, including its entire view hierarchy, even after onDestroy runs and the system considers the screen finished. This is exactly the kind of leak LeakCanary is built to catch: an object the framework thinks is gone but that a stray reference is still holding onto. The fix is simply not to store Activity or View references statically; if something genuinely needs process-wide access to a Context, applicationContext is the safe choice because it's meant to live that long.

// BAD - static field keeps the Activity alive for the whole process
companion object {
    var instance: MyActivity? = null
}

A subtler version of the same bug hides in non-static inner classes, most often a Handler's Runnable. In Kotlin, 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 entire Activity reachable until it runs. The fix has two parts: reach the outer object through a WeakReference instead of an implicit strong one, so the Activity can still be collected even while the Runnable exists, and clear any pending work in onDestroy so nothing stale is left queued. The same discipline extends beyond Handlers: any listener, sensor callback, or BroadcastReceiver you register against an Activity needs a matching unregister, ideally in the paired lifecycle callback, or a lifecycle-aware component that does it for you.

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)
    }
}

When you genuinely want a reference that doesn't block collection, Android gives you two flavors with very different clearing behavior, and interviewers like asking which is which. A WeakReference is cleared at the very next garbage collection cycle once its referent is only weakly reachable, it's for cases where you want to know an object still exists without ever being the reason it stays alive, exactly the Activity-inside-a-Handler pattern from the previous chunk. A SoftReference is kept around much longer, only cleared once the heap is genuinely under memory pressure, just before an out of memory error would otherwise occur. That makes SoftReference suited to a memory-sensitive cache: hold onto data as long as there's room, but let it go before the app crashes. If someone asks which reference type to use for a cache, the answer is a SoftReference or, better, a bounded LruCache; if they ask which one avoids a leak in a callback, the answer is WeakReference.

val weakBitmap: WeakReference<Bitmap> = WeakReference(bitmap)
val softCache: SoftReference<Bitmap> = SoftReference(bitmap)

Framework code solves several of these leak patterns for you if you use it correctly, and ViewModel is the clearest example. 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 torn down and recreated. It's only cleared, with onCleared invoked, when the owning Activity or Fragment is finishing for real, not just rotating. That matters for coroutines too: launching work in viewModelScope instead of a raw CoroutineScope gets you structured cancellation for free, because 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 no longer exists.

class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            val result = withContext(Dispatchers.IO) { repository.fetch() }
            _uiState.value = result
        }
    }
}

Anything that lives for the whole process, a singleton, a dependency-injection module, a manager class, needs a Context to do useful things like reading resources or starting a service, and which Context it holds matters enormously. If a process-wide singleton holds an Activity's Context, or a View's Context, it pins that Activity and its entire view tree in memory for as long as the process runs, long after the screen that created it is gone. applicationContext is the safe choice: it's tied to the process itself, so a long-lived object can hold it forever without pinning anything that was supposed to be temporary.

object MySingleton {
    lateinit var context: Context
    fun init(ctx: Context) {
        context = ctx.applicationContext
    }
}

The other side of memory trouble is running out entirely. Every app has a hard per-process heap cap, and bitmaps are usually what blows past it: a one thousand by one thousand pixel bitmap decoded as ARGB_8888, four bytes per pixel, costs about four megabytes on its own, and a screen full of full-resolution photos adds up fast toward an OutOfMemoryError. The defenses cut the byte cost directly rather than hoping the garbage collector saves you: downsample with inSampleSize so you decode fewer pixels in the first place, and switch to RGB_565, two bytes per pixel, when you don't need an alpha channel. In practice, an image-loading library like Coil or Glide applies both of these automatically and manages a bounded, evictable cache, which is usually a better answer in an interview than hand-rolling BitmapFactory.Options everywhere.

val options = BitmapFactory.Options().apply {
    inSampleSize = 4
    inPreferredConfig = Bitmap.Config.RGB_565
}
val bitmap = BitmapFactory.decodeFile(path, options)

onTrimMemory gives you a callback well before an OutOfMemoryError, telling you 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 stops being visible, the right time to drop anything tied purely to what was on screen: bitmap caches, view caches, anything cheap enough to reload later. While the process keeps running but memory gets tight, you get RUNNING_MODERATE, then LOW, then CRITICAL in turn, each one asking you 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, so hold onto only what you genuinely can't afford to lose.

override fun onTrimMemory(level: Int) {
    super.onTrimMemory(level)
    if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
        imageCache.evictAll()
        thumbnailCache.clear()
    }
}

Two categories of tooling catch what discipline alone misses, and they work at different points in the process. 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 whatever is holding it. 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 garbage collection and see what survives it. StrictMode is different again, it doesn't find problems after the fact, it flags the habits that cause them while you're still writing the code. Its thread policy watches the main thread for the I/O you're not supposed to do there, disk reads, disk writes, network calls, while its VM policy is the leak detector, flagging leaked Closeables, unclosed SQLite cursors, and Activity instances that outlive their expected lifetime.

if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectNetwork()
            .penaltyLog()
            .build()
    )
    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectLeakedClosableObjects()
            .detectActivityLeaks()
            .penaltyLog()
            .build()
    )
}

Everything so far helps prevent problems, but production ANRs and low-memory kills still happen in the wild, often on a device you'll never personally see. Since API 30, ActivityManager's getHistoricalProcessExitReasons gives you a way to look backward: it returns a list of ApplicationExitInfo records describing why your process died on its last few runs, whether the reason was 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. It's a postmortem tool, meant to be called early in Application.onCreate on the next launch, not something that monitors the app live.

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", "Previous ANR at ${info.timestamp}")
    }
}

Back to ANR & Memory Management