How to Fix ANRs in Production Android Apps

Application Not Responding errors are frustrating because the app may not crash at all. Instead, the screen freezes, taps stop working, and users eventually force-close the app.
Crashlytics may not show a traditional exception, but Android Vitals can still reveal the problem. In most cases, an ANR happens because the main thread is blocked by database work, network calls, JSON parsing, image processing, lock contention, or heavy startup logic.
The goal is simple:
Keep the main thread free for input, lifecycle events, and rendering.
What Is an ANR?
Android uses the main thread to:
Process touch and key events.
Run lifecycle callbacks.
Dispatch messages.
Update the UI.
Coordinate rendering.
Start activities and respond to system events.
When the main thread is blocked for too long, Android considers the app unresponsive. An input-dispatch ANR commonly occurs when the app cannot respond to user input for approximately five seconds, although the exact timeout depends on the ANR type. Broadcast receivers, services, and content providers have different timeout rules.
An ANR does not require an infinite loop. This innocent-looking code can still freeze the app:
val orders = database.orderDao().getAllOrders()It may work quickly on a flagship device with 500 rows but take several seconds on an older phone with a large database and slower storage.
Why ANRs Usually Appear in Production
ANRs are difficult to reproduce because production users have different:
Device performance.
Android versions.
Database sizes.
Network conditions.
Memory pressure.
Storage availability.
Battery settings.
App usage patterns.
A flagship development phone can hide problems that affect Samsung A-series, Redmi, POCO, Oppo, Realme, Android Go, or other low-memory devices.
The visible freeze may also be misleading. A frozen order screen could actually be caused by:
A database query during
ViewModelcreation.A blocking binder call.
Large JSON parsing.
A lock held by another thread.
Heavy initialization in
Application.onCreate().Synchronous work inside a
BroadcastReceiver.
Do not guess from the screen that froze. Start with the production trace.
Start With Android Vitals
Open:
Play Console → Android Vitals → Crashes and ANRs
Google Play can show affected versions, devices, Android versions, ANR types, and stack traces when available.
Check these details first:
Did the problem start after a specific release?
Which devices and Android versions are affected?
Is the issue concentrated in a particular manufacturer?
Is it an input, broadcast receiver, service, or startup ANR?
Is the user-perceived ANR rate increasing?
Which app version has the highest rate?
If the issue appeared immediately after a release, inspect the code changed in that version first.
The user-perceived ANR rate deserves special attention because it represents ANRs that users are more likely to experience directly, such as a frozen screen or unresponsive interaction.
Read the Stack Trace
Consider this simplified trace:
"main" tid=1 Blocked
at android.database.sqlite.SQLiteCursor.fillWindow(...)
at com.app.orders.OrderRepository.getOrderHistory(OrderRepository.kt:67)
at com.app.orders.OrderHistoryViewModel.<init>(OrderHistoryViewModel.kt:34)This suggests:
The main thread is blocked.
SQLite is performing the operation.
The call came from
OrderRepository.The query was started while creating the
ViewModel.
The real problem is not simply “Room is slow.” The problem is that potentially slow database work was started synchronously on the main thread.
Common trace clues
Trace clue | Possible cause |
|---|---|
| Database query |
| File or disk access |
| Image decoding |
| Large JSON parsing |
| Expensive sorting |
| System or cross-process call |
| Waiting for another thread |
| Lock contention |
| Synchronous preference write |
The first application-owned frame is often the most useful place to begin.
Do not inspect only the main thread. Service, broadcast receiver, provider, binder, and lock-related ANRs may require examining other threads and sometimes system_server.
Reproduce Real User Conditions
When QA cannot reproduce the issue, make the test environment worse.
Test with:
A mid-range or low-end device.
The oldest affected Android version.
A release or minified build.
A cold app start.
A large local database.
Slow or unstable network.
Battery Saver enabled.
Low storage and memory pressure.
Large images and long lists.
Existing user data instead of a fresh install.
Use realistic data:
500 rows: basic testing
10,000 rows: useful performance testing
100,000 rows: pagination and indexing testing
Large images: decoding and memory testingA query that takes 80 milliseconds on a flagship device may take several seconds on slower hardware.
Enable StrictMode
StrictMode can expose accidental disk and network work on the main thread during development.
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.penaltyFlashScreen()
.build()
)
}You can also detect certain VM-level problems:
if (BuildConfig.DEBUG) {
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedClosableObjects()
.detectActivityLeaks()
.penaltyLog()
.build()
)
}StrictMode is an early warning system, not a complete ANR detector. It may not identify expensive sorting, large JSON parsing, or every lock-contention problem.
Use it in debug or internal builds—not production.
Confirm the Timeline With Perfetto
A stack trace tells you where a thread was observed. Perfetto or System Trace helps explain what happened over time.
Use:
Android Studio System Trace.
Perfetto.
CPU Profiler.
Macrobenchmark for repeatable startup and interaction tests.
Look for:
Long blocks on the main thread.
SQLite activity.
Binder transactions.
Garbage collection.
RenderThread delays.
Input events waiting.
Lock contention.
system_serveractivity.
For example:
Main thread: SQLite query — 7.2 seconds
RenderThread: waiting
Input events: queuedThis confirms that the UI was unresponsive because the main thread was occupied by a database operation.
A running main thread may be doing expensive computation. A blocked or sleeping main thread may be waiting for disk I/O, a database lock, another thread, or a binder response. The fix depends on which situation you find
Move Slow Work Off the Main Thread
This pattern is risky:
class OrderHistoryViewModel(
private val repository: OrderRepository
) : ViewModel() {
val orders = repository.getOrderHistory()
}If the repository performs disk I/O, this code may run on the main thread.
Use a coroutine:
class OrderHistoryViewModel(
private val repository: OrderRepository
) : ViewModel() {
private val _orders = MutableStateFlow<List<Order>>(emptyList())
val orders = _orders.asStateFlow()
init {
viewModelScope.launch {
_orders.value = repository.getOrderHistory()
}
}
}Keep the dispatcher decision inside the repository:
class OrderRepository(
private val orderDao: OrderDao
) {
suspend fun getOrderHistory(): List<Order> =
withContext(Dispatchers.IO) {
orderDao.getAllOrders()
}
}This protects every caller from accidentally running the query on the main thread.
Use dispatchers according to the type of work:
Dispatchers.IOfor blocking database, file, and network operations.Dispatchers.Defaultfor CPU-heavy sorting, compression, encryption, and transformations.Main thread for lightweight state updates and UI rendering.
Do not move everything to a background thread blindly. Also reduce the work itself.
Fix the Query, Not Just the Thread
Moving a query to Dispatchers.IO prevents the UI from freezing, but a slow query can still waste memory, battery, and time.
Review whether the query:
Loads the entire table.
Reads unnecessary columns.
Lacks indexes.
Performs expensive joins.
Sorts a large result in memory.
Returns data the screen does not need.
Prefer pagination and targeted queries:
@Query(
"""
SELECT id, customerName, total, createdAt
FROM orders
ORDER BY createdAt DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getOrders(
limit: Int,
offset: Int
): List<Order>Use database-level filtering and sorting where possible. Add indexes based on real query patterns, then measure the result.
The same principle applies to JSON parsing, image processing, and list transformations: move heavy work away from the main thread and reduce the amount of work required.
Avoid Heavy Startup Work
Startup ANRs are especially harmful because the user cannot even reach the first screen.
Avoid doing this:
override fun onCreate() {
super.onCreate()
analytics.init()
database.open()
remoteConfig.fetchBlocking()
preloadEverything()
}Ask of every startup task:
Is it required before the first screen?
Can it run after the first frame?
Can it be initialized lazily?
Can it start only when the feature is opened?
What happens if it takes 10 seconds?
Keep Application.onCreate() limited to essential initialization. Delay analytics, remote configuration, preloading, and noncritical SDK work whenever possible.
Measure startup using Android Studio, Perfetto, and startup benchmarks instead of guessing. Android recommends tracing and benchmarking to identify startup bottlenecks.
Watch Receivers, Services, and Locks
Broadcast receivers
Avoid long database, network, or computation work inside onReceive().
For short background handoff, goAsync() may help:
override fun onReceive(
context: Context,
intent: Intent
) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
syncData(context)
} finally {
pendingResult.finish()
}
}
}goAsync() is not designed for unlimited work. Use WorkManager or another appropriate scheduler for substantial or deferrable tasks.
Locks
Do not hold a lock while performing slow work:
synchronized(cacheLock) {
refreshCacheFromDisk()
}Instead, perform slow work first and update shared state briefly:
val data = withContext(Dispatchers.IO) {
readCacheFromDisk()
}
synchronized(cacheLock) {
cache = data
}Also investigate these calls on the main thread:
runBlocking { ... }
future.get()
latch.await()They can wait indefinitely or for several seconds.
Compose and Large Lists
Do not perform blocking work directly inside a composable:
@Composable
fun OrderScreen(repository: OrderRepository) {
val orders = repository.getOrderHistory() // Wrong
}Load data through a ViewModel:
@Composable
fun OrderScreen(viewModel: OrderHistoryViewModel) {
val orders by viewModel.orders.collectAsStateWithLifecycle()
OrderList(orders)
}For large lists:
Use lazy layouts.
Use stable keys.
Avoid sorting during recomposition.
Avoid rebuilding large collections unnecessarily.
Use pagination.
Filter and sort in the database when possible.
Decode images off the main thread with an image-loading library.
A loading indicator cannot help if the main thread is blocked before it can draw the indicator.
Common Fixes That Fail
“Increase the timeout”
This does not make the UI responsive. It may keep the blocking operation alive longer.
“Use Dispatchers.IO everywhere”
This may hide the main-thread problem while leaving a huge query, lock, memory issue, or startup dependency chain untouched.
“Add a progress indicator”
The indicator also needs the main thread to render it.
“Test only on a flagship phone”
This validates the best-case environment, not the production environment.
“Rely only on Crashlytics”
ANRs are not ordinary crashes. Use Android Vitals, traces, StrictMode, and Perfetto together.
Release and Monitor the Fix
Roll out the fix gradually when possible:
5% → 25% → 50% → 100%At each stage, monitor:
Overall ANR rate.
User-perceived ANR rate.
Affected devices.
Android versions.
App startup performance.
Reviews mentioning freezes.
New crashes or battery problems.
Compare the fixed version with the last healthy version. If the original cluster disappears but a new cluster appears, the bottleneck may have moved rather than been eliminated.
Final Checklist
Before declaring the problem fixed, verify that:
The main thread performs no database or file I/O.
Network calls do not block UI callbacks.
Large JSON responses are parsed off the main thread.
Images are decoded and resized in the background.
Large lists use pagination or lazy loading.
Sorting and filtering are efficient.
runBlocking,Future.get(), andCountDownLatch.await()are not used on the main thread.Locks are not held during slow operations.
Startup contains only essential work.
Receivers and service callbacks remain short.
StrictMode is enabled during development.
Perfetto has been used for unexplained cases.
Testing includes slow devices and realistic data.
The fix is monitored through a staged rollout.
ANR debugging becomes much easier when treated as a production investigation rather than a random search for slow code:
Android Vitals shows where the issue occurs.
The trace shows what the app was waiting for.
Perfetto shows the timeline.
StrictMode catches many mistakes early.
Good repository boundaries keep blocking work away from the main thread.
Keep the main thread available for input, lifecycle work, and rendering. Move slow work away from it—and reduce the amount of slow work your app needs to perform.
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Get the latest updates
Join our newsletter and never miss an article.