Analyzing ANR Clusters in Play Vitals

AndroidPerformancePlay VitalsDebugging
Share on LinkedIn

ANRs are the performance failure users feel most viscerally — the app freezes, then Android offers to kill it — and they're the metric Google weighs heavily in Play's core vitals. The trap teams fall into is treating each ANR report as an individual bug. There are thousands of reports; you cannot fix them one at a time. The skill is clustering: recognizing that those thousands of reports collapse into a handful of root causes, and fixing the cause behind each cluster. I've turned a scary-looking ANR dashboard into three actual bugs more than once, and the method is repeatable.

What an ANR trace actually contains

When the main thread can't service input for ~5 seconds, Android snapshots every thread's stack and reports it. In Play Vitals you get these aggregated. The single most important thing in the whole trace is the main thread's top frames — that's what the UI thread was stuck doing when the watchdog fired. Everything else is context for why the main thread was stuck.

Two broad shapes cover most ANRs:

The first is "you did too much on the main thread." The second is "you serialized the main thread behind something slow." They're diagnosed differently, which is why reading the trace correctly matters.

Clustering: turn thousands of reports into a few bugs

Play Vitals already groups ANRs by similarity, but the grouping isn't always the root-cause grouping you want. My process:

  1. Sort clusters by user-perceived ANR rate, not raw count. A cluster hitting 0.1% of sessions matters more than one that's cosmetically frequent but rare per-user.
  2. For each cluster, read the aggregated main-thread stack. Identify the top app frame (skip framework frames) — that's the anchor for the cluster's root cause.
  3. Merge clusters that share a root cause. Vitals may split one bug across several signatures (different call sites into the same slow method). If three clusters all bottom out in Database.query on the main thread, that's one bug.
  4. Rank by impact × fixability. Fix the high-rate, clearly-diagnosable clusters first. A vague native-side stall you can't reproduce goes to the bottom.

The mindset shift is treating clusters as hypotheses about a root cause rather than as a list of things to individually close.

Reading the two ANR shapes

Main thread doing work. The top app frame is the answer. If you see readFromDisk, SharedPreferences commit, Gson.fromJson, or a ContentResolver.query on the main thread, the fix is to move it off — coroutines on a background dispatcher, and don't block on the result.

main (RUNNABLE):
  at com.example.Repo.loadSettings   <-- reading a large file synchronously
  at com.example.MainActivity.onCreate

Main thread blocked on a lock. The main thread's stack shows waiting to lock <0x...> in a monitor state. Now you have to find who holds that lock — search the other threads in the trace for the one holding 0x..., often doing something slow. The bug isn't the main thread; it's the background thread hogging a shared lock.

main (BLOCKED):
  waiting to lock <0x0c3f> held by thread 14
thread-14 (RUNNABLE):
  at com.example.Cache.rebuild        <-- slow work holding the lock main needs

This second shape is the one people misread — they "optimize" the main-thread frame that's just waiting, and the real fix is shrinking the critical section or removing the lock contention on thread-14.

Common root causes I keep finding

Cluster signature Root cause Fix direction
Main thread in file/DB/prefs I/O Synchronous I/O on UI thread Move to background dispatcher
Main thread BLOCKED on lock Contended lock held by slow bg work Shrink critical section / avoid shared lock
Main thread in Binder transact Slow IPC / ContentProvider Async call, cache result
ANRs spike at startup Heavy Application.onCreate init Defer/lazy-init SDKs
Main thread in large parse Big JSON/bitmap on UI thread Parse off main thread, stream

Startup ANRs deserve special mention: if your Application.onCreate synchronously spins up ten SDKs, a slow device stalls past the threshold before your first screen even shows. That's the same startup-hygiene lever behind ProfileInstaller and startup performance — do less on the critical path, and do it asynchronously.

Reproducing and confirming

An ANR you can't reproduce is hard to confirm fixed. Techniques that help:

Then watch the rate per release. ANR rate is noisy in aggregate but clear per-version: a fix should visibly drop the cluster's rate in the release that ships it. If it doesn't, your hypothesis about the root cause was wrong — back to the trace.

What I'd take away

ANRs are a clustering problem, not a whack-a-mole of individual reports. Read the main thread's top frames first: either it's doing work it shouldn't (move it off the main thread) or it's blocked on a lock (find and fix the thread holding it). Merge Vitals clusters that share a root cause, prioritize by user-perceived rate times fixability, and confirm each fix by watching that cluster's rate drop in the shipping release. Add StrictMode and slow-device testing to catch main-thread stalls before users do. Handled this way, a thousand-report ANR dashboard becomes three or four real bugs — which is a very fixable number.

Resources

Frequently asked questions

What counts as an ANR on Android?

An ANR (Application Not Responding) fires when the main thread is blocked too long to handle user input or a broadcast — commonly around 5 seconds for input dispatch. The system captures a trace of all threads at that moment and, if the app is distributed on Play, reports it in Android Vitals. ANRs directly hurt your Play Store standing because they're part of the core vitals thresholds.

How do I find the root cause of an ANR from its trace?

Start with the main thread's stack at the top of the ANR trace — it shows what the main thread was doing when it stalled. If it's blocked on a lock, find which other thread holds that lock; if it's in I/O or a synchronous call, that's your culprit. Group ANRs with similar main-thread frames into clusters so you fix one root cause per cluster rather than chasing individual reports.

What ANR rate should I aim for?

Google's bad-behavior threshold in Vitals is a user-perceived ANR rate around 0.47%, and exceeding it can affect your app's visibility. Realistically you want to be well under that, ideally below 0.2%, since ANRs cluster and a single bad release can spike the rate. Track the rate per release so a regression is caught before it spreads across your install base.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →