Android ANR deep dive: Tracing main thread stalls
Meta description: Master Android ANR diagnosis using Perfetto traces. Learn to identify binder contention, input dispatch watchdog internals, and Choreographer vsync budget failures killing your Play Store rating.
Tags: android kotlin mobile architecture cleanarchitecture
TL;DR
ANRs are silent rating killers. The three patterns responsible for the majority of Play Console ANR reports are binder transaction spill from background services, input dispatch watchdog expiry from synchronous I/O on the main thread, and Choreographer vsync budget exhaustion. All three are diagnosable with Perfetto in under 30 minutes if you know where to look.
Why ANRs are worse than crashes
Most teams treat ANRs as edge cases. Crash rates are visible and dramatic. ANRs are quiet — the user just stares at a frozen screen, then leaves a one-star review.
The Android system issues an ANR when:
| Trigger | Timeout | Common Cause |
|---|---|---|
| Input dispatch unresponsive | 5 seconds | Main thread blocked on I/O or lock |
| Broadcast receiver execution | 10 seconds (foreground) | Synchronous work in onReceive() |
| Service start/bind | 20 seconds | Blocking call inside onCreate() |
| Content provider | 10 seconds | Heavy query on main thread |
Five seconds is nothing. A single blocking binder call to a system service under memory pressure will eat that budget whole.
Pattern 1: Binder transaction spill
Binder is Android’s IPC mechanism, and it bites you when background services saturate the binder thread pool. When all 16 binder threads in a process are occupied, the next synchronous binder call from any thread — including the main thread — blocks.
In Perfetto, open a system trace and filter for binder_transaction slices on the main thread. If you see the main thread parked in binder_suspend while your WorkManager job hammers PackageManager or AccountManager, that’s your culprit.
Audit every synchronous IPC call from non-UI threads. Rate-limit background binder traffic, and move any main-thread binder calls that aren’t latency-critical to a coroutine dispatcher.
// Before: blocking main thread
val packages = packageManager.getInstalledPackages(0)
// After: offload to IO dispatcher
val packages = withContext(Dispatchers.IO) {
packageManager.getInstalledPackages(0)
}
Pattern 2: Input dispatch watchdog internals
The InputDispatcher runs in system_server. It tracks touch and key events and expects the app’s main thread to dequeue and process them within 5 seconds. If your main thread is stuck — on a lock, a database query, or a synchronous SharedPreferences commit — the watchdog fires.
In Perfetto, look at the InputReader and InputDispatcher tracks in system_server. Match the timestamp of the ANR report against main thread slices in your app process. The blocking slice will be sitting there visibly, often a monitor lock acquisition or a Cursor.moveToFirst() call.
The most common offender in Kotlin codebases I’ve reviewed in production: runBlocking on the main thread when a coroutine scope isn’t available, combined with a downstream suspension point that hits the network or disk.
// This is an ANR waiting to happen
class MyActivity : AppCompatActivity() {
override fun onCreate(...) {
val result = runBlocking { repository.fetchConfig() } // NEVER
}
}
Replace runBlocking on the main thread unconditionally. Use lifecycleScope.launch and model loading state explicitly.
Pattern 3: Choreographer vsync budget exhaustion
The Choreographer receives a vsync signal every ~16.6ms at 60 Hz (or ~8.3ms at 120 Hz). Every frame, it must complete INPUT → ANIMATION → TRAVERSAL within budget. If your onDraw(), onMeasure(), or any attached doFrame callback runs long, frames stack up. Sustained overruns cause the system to conclude the app is unresponsive.
In Perfetto, the Choreographer#doFrame slice on the main thread tells you precisely where the budget is going. Use FrameMetrics in production to instrument this without a trace.
window.addOnFrameMetricsAvailableListener({ _, frameMetrics, _ ->
val totalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
if (totalDuration > 16_000_000L) {
// log or report to your observability stack
}
}, Handler(Looper.getMainLooper()))
The systematic diagnosis workflow
- Pull the ANR trace from Play Console (
tombstoneoranrfile) - Record a Perfetto system trace reproducing the condition locally
- Cross-reference the main thread stack in the tombstone with Choreographer and binder slices in Perfetto
- Confirm with
adb shell am bug-reportif device access is available
Takeaways
Pull a Perfetto trace for every ANR category in Play Console before you touch a line of code. The stack in the tombstone tells you where — the trace tells you why. Fixing the wrong thing is worse than fixing nothing.
Ban synchronous operations on the main thread at the architecture level. Enforce it with a custom lint rule that flags runBlocking, synchronous SharedPreferences.commit(), and main-thread cursor queries. Review fatigue is real; the rule will catch what code review misses.
Instrument FrameMetrics in production. Choreographer budget overruns compound silently, and by the time they show up in your ANR rate they’ve already done damage to your store ranking. Surface them through your existing observability pipeline now, not after the reviews roll in.