---
title: "Diagnose Hidden ANR Patterns in Jetpack Compose Apps"
published: true
description: "Learn how Dispatchers.Main.immediate and synchronized Room DAO callbacks create ANR-risk blocking that StrictMode misses. Use Perfetto traces and CI gates to catch them."
tags: android, kotlin, architecture, performance
canonical_url: https://blog.mvpfactory.co/diagnose-hidden-anr-patterns-jetpack-compose
---
## What We Will Build
In this workshop, I will walk you through a systematic workflow for diagnosing ANR patterns that StrictMode completely misses. By the end, you will have:
- A clear mental model of how `Dispatchers.Main.immediate` plus `synchronized` Room DAO callbacks silently block the main thread
- A working Perfetto SQL query to surface lock contention across threads
- A three-layer CI gate architecture (lint, macro-benchmark, trace analysis) that catches these patterns before they ship
Let me show you a pattern I use in every project — and the one that kept slipping through our tooling for months.
## Prerequisites
- Android Studio Hedgehog or later
- A Jetpack Compose app with Room database access
- `adb` and Perfetto CLI available on your PATH
- Familiarity with Kotlin coroutines and dispatchers
## Step 1: Understand the Gap StrictMode Leaves Open
StrictMode catches disk reads, network calls, and untagged sockets on the main thread. Here is the gotcha that will save you hours: **it does not instrument lock acquisition**. If your main thread calls a `suspend` function that internally acquires a `synchronized` lock held by a Room write transaction on `Dispatchers.IO`, StrictMode reports nothing. The main thread is technically not doing I/O — it is waiting on a monitor.
In production Compose-heavy apps that have already eliminated obvious StrictMode violations, this pattern accounts for roughly 15–30% of ANR clusters.
### The Invisible Call Chain
Here is the typical sequence:
1. A `LaunchedEffect` calls a repository method on `Dispatchers.Main.immediate`
2. The repository calls a Room DAO method annotated with `@Transaction`
3. Room's generated code acquires a `synchronized` lock on the `RoomDatabase` instance
4. A background `Dispatchers.IO` coroutine is already holding that lock (bulk insert, migration, or WAL checkpoint)
5. The main thread blocks on monitor entry. Zero StrictMode output.
kotlin
// Looks safe. It is not.
@Composable
fun DashboardScreen(viewModel: DashboardViewModel) {
LaunchedEffect(Unit) {
// Dispatchers.Main.immediate by default
viewModel.refreshStats() // suspend, calls Room @Transaction
}
}
The `suspend` keyword lulls teams into thinking this is non-blocking. But Room's internal `synchronized` block does not suspend. It blocks the calling thread.
## Step 2: Capture and Analyze with Perfetto
Perfetto captures thread state transitions that Systrace and StrictMode cannot. Here is the step-by-step workflow:
| Step | Tool | What You Find |
|------|------|---------------|
| 1. Capture trace | `adb shell perfetto` with `sched` + `lock_contention` data sources | Raw thread scheduling data |
| 2. Find ANR window | Perfetto UI, search for `SIG_ANR` or `Input dispatching timed out` | Exact timestamp of ANR trigger |
| 3. Inspect main thread | Slice track, look for `monitor contention` slices | Lock address + blocked duration |
| 4. Cross-reference holder | Filter by lock address across all thread tracks | Background thread holding the lock |
| 5. Read holder stack | Holder thread's slice track at same timestamp | Exact call chain (e.g., Room `beginTransaction`) |
### The Perfetto Query That Matters
sql
SELECT ts, dur, thread.name, args.display_value
FROM slice
JOIN thread_track ON slice.track_id = thread_track.id
JOIN thread USING (utid)
WHERE slice.name LIKE '%monitor contention%'
AND thread.name = 'main'
AND dur > 100000000 -- >100ms, ANR-risk threshold
ORDER BY dur DESC
This query surfaces every main-thread lock wait exceeding 100ms. In one production audit, I found 11 distinct lock-contention sites that had passed StrictMode checks for months. Eleven. All invisible to the existing tooling.
## Step 3: Build a CI Gate for ANR-Risk Chains
Waiting for production ANRs is expensive and demoralizing. Here is the minimal setup to get this working as a CI gate.
### Static Analysis with Custom Lint Rules
kotlin
// Custom Lint detector: flag @Transaction calls reachable from Main dispatcher
class MainThreadTransactionDetector : Detector(), SourceCodeScanner {
override fun getApplicableMethodNames() = listOf("withTransaction")
override fun visitMethodCall(
context: JavaContext,
node: UCallExpression,
method: PsiMethod
) {
if (isReachableFromMainDispatcher(context, node)) {
context.report(
ANR_RISK_ISSUE, node, context.getLocation(node),
"Room @Transaction reachable from Dispatchers.Main"
)
}
}
}
### The Three-Layer Pipeline
| Stage | Check | Threshold |
|-------|-------|-----------|
| Lint | Custom `MainThreadTransactionDetector` | 0 warnings |
| Instrumented test | Macro-benchmark with Perfetto trace capture | Main-thread lock wait < 50ms |
| Trace analysis | Automated Perfetto SQL query on CI traces | 0 slices > 100ms |
The macro-benchmark stage matters most. Run realistic user flows (app cold start, navigation between Compose screens, data sync) while capturing Perfetto traces. Parse the traces with the SQL query above and fail the build if any main-thread lock contention exceeds your threshold.
## Step 4: Apply the Fix
Once you identify a lock-contention site, the fix is straightforward — never acquire Room's database lock from a main-thread coroutine.
kotlin
// Before: ANR risk
suspend fun refreshStats() {
val stats = dao.getStatsInTransaction() // blocks main thread on lock
_state.value = stats
}
// After: explicit dispatcher switch before lock acquisition
suspend fun refreshStats() {
val stats = withContext(Dispatchers.IO) {
dao.getStatsInTransaction() // lock acquired on IO thread
}
_state.value = stats
}
`withContext(Dispatchers.IO)` ensures the `synchronized` block executes on a thread that can safely block without causing ANRs.
## Gotchas
- **The `suspend` modifier lies (sometimes).** A `suspend` DAO method does not prevent the underlying `synchronized` block from blocking the calling thread. Be explicit about which dispatcher acquires locks.
- **StrictMode green does not mean ANR-safe.** Lock contention is an entirely separate failure mode. Do not treat passing StrictMode as full coverage.
- **Perfetto `lock_contention` requires kernel support.** Some emulator images and older devices do not emit these events. Test on physical hardware or recent API-level emulators.
- **Threshold tuning matters.** Start with 100ms as a hard failure and 50ms as a warning. Tighten as your codebase matures.
I keep long debugging sessions like these healthy with [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) for break reminders, because staring at Perfetto traces for hours without moving is its own kind of system failure.
## Wrapping Up
Stop trusting StrictMode alone for ANR prevention. Add Perfetto trace analysis to your instrumented test suite, wrap every Room `@Transaction` call in `withContext(Dispatchers.IO)`, and build your CI gate in three layers: static lint, macro-benchmark traces, and a zero-tolerance threshold for main-thread lock waits. Catch the pattern before your users do.
Top comments (0)