---
title: "Eliminating Android ANRs: From 2.1% to 0.08% ANR Rate"
published: true
description: "A hands-on workshop for fixing the three root causes behind most production ANRs — SharedPreferences traps, binder limits, and broadcast receiver timeouts."
tags: android, kotlin, architecture, mobile
canonical_url: https://blog.mvpfactory.co/eliminating-android-anrs-from-2-1-to-0-08-anr-rate
---
## What We Will Build
In this workshop, we are going to systematically eliminate ANRs from a production Android app. I will walk you through the three root causes responsible for roughly 80% of ANR occurrences in mature codebases, and show you the exact fixes that dropped our ANR rate from 2.1% to 0.08%.
By the end, you will have a custom ANR watchdog, a zero-regression DataStore migration wrapper, a ContentProvider-based payload strategy, and a coroutine-backed BroadcastReceiver pattern you can drop into any project.
## Prerequisites
- A Kotlin-based Android project (minSdk 21+)
- Familiarity with Kotlin coroutines and Jetpack DataStore
- Access to your Play Console vitals (for benchmarking your current ANR rate)
## Step 1: Instrument ANR Detection With a Watchdog
Don't wait for Play Console to tell you about ANRs. Let me show you a pattern I use in every project — a main-thread watchdog that catches ANRs in staging before they reach users:
kotlin
class ANRWatchdog(private val timeoutMs: Long = 5000L) : Thread("ANR-Watchdog") {
private val ticker = AtomicLong(0)
override fun run() {
while (!isInterrupted) {
val start = ticker.get()
Handler(Looper.getMainLooper()).post { ticker.incrementAndGet() }
sleep(timeoutMs)
if (ticker.get() == start) {
reportANR(Looper.getMainLooper().thread.stackTrace)
}
}
}
}
This posts to the main looper and checks whether the message was processed within the timeout. If not, it captures the main thread's stack trace. I wish we had added this six months earlier.
## Step 2: Migrate SharedPreferences to DataStore
Here is the gotcha that will save you hours. `SharedPreferences.apply()` is marketed as asynchronous — it is, until `Activity.onPause()` fires. The `ActivityThread` runs `QueuedWork.waitToFinish()` during lifecycle transitions, blocking the main thread until every pending `apply()` completes its disk write.
The docs do not mention this, but every `apply()` call is a latent ANR during lifecycle transitions. Here is the minimal setup to get a safe migration working — a wrapper interface that lets you swap implementations file-by-file without changing call sites:
kotlin
interface KVStore {
suspend fun getString(key: String, default: String = ""): String
suspend fun putString(key: String, value: String)
}
class DataStoreKVStore(
private val dataStore: DataStore
) : KVStore {
override suspend fun getString(key: String, default: String): String =
dataStore.data.map { it[stringPreferencesKey(key)] ?: default }.first()
override suspend fun putString(key: String, value: String) {
dataStore.edit { it[stringPreferencesKey(key)] = value }
}
}
We migrated 34 SharedPreferences files over three sprints with no regressions using this approach behind a feature flag.
## Step 3: Route Large Payloads Through a ContentProvider
The binder transaction buffer is capped at 1MB per process, shared across all concurrent IPC calls. For payloads exceeding 100KB, stop using Intent extras and pipe through a `ContentProvider`:
kotlin
fun writePayloadToProvider(context: Context, data: ByteArray): Uri {
val uri = PayloadContentProvider.createUri(UUID.randomUUID().toString())
context.contentResolver.openOutputStream(uri)?.use { stream ->
data.inputStream().copyTo(stream, bufferSize = 8192)
}
return uri // Pass this URI in the Intent instead
}
Enforce a 100KB ceiling on Intent extras. A debug-build lint check that logs Bundle sizes above the threshold takes an hour to write and saves weeks of debugging.
## Step 4: Fix BroadcastReceiver Timeouts With goAsync()
BroadcastReceivers get a strict 10-second timeout for foreground broadcasts on the main thread. Any synchronous database query triggers an ANR. Use `goAsync()` paired with coroutines:
kotlin
class SyncReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
repository.performSync(intent.action)
} finally {
pending.finish()
}
}
}
}
`goAsync()` returns a `PendingResult` that extends the window to 30 seconds and releases the main thread immediately.
## Gotchas
- **`apply()` looks safe but is not.** `commit()` blocks obviously; `apply()` blocks silently at lifecycle transitions. Both are high risk during `onPause()`. DataStore with coroutines carries zero risk.
- **The 1MB binder limit is per-process, not per-call.** Multiple concurrent IPC calls share that buffer. You can hit `TransactionTooLargeException` — or worse, a silent ANR — well below 500KB per individual payload.
- **`goAsync()` buys time, not immunity.** You still get 30 seconds. Always dispatch to `Dispatchers.IO` and keep the work bounded.
- **Enable StrictMode in debug builds.** It flags disk reads/writes and network calls on the main thread. Pair it with the watchdog for full coverage.
## Conclusion
ANRs are not mysterious. They are deterministic — the main thread is blocked for 5+ seconds, and every occurrence has a traceable root cause. Google flags apps above a 0.47% ANR rate, which directly hurts your store ranking.
The audit process: enable StrictMode, deploy the watchdog, grep for `.apply()` and `.commit()`, log Bundle byte sizes, and review every `BroadcastReceiver` subclass. Instrument early, audit systematically, and keep the main thread clear. That is how you go from 2.1% to 0.08%.
Top comments (0)