Skip to content
← Blog
EN #android#kotlin#performance#anr#yembi

The ANRs tutorials never mention: debugging Android on the phones people actually use

A real production ANR/OOM pass on Yembi: cold-start herds, ARMv7 classloading, binder storms, and the guardrail that stops them coming back.

There’s a post going around about how Android tutorials teach you Kotlin, Jetpack and Compose but never the thing that actually decides whether your app survives: the ANRs, the OOM kills, the jank that only show up in production. The advice is to read the AOSP source, because the answers are already in there. It’s good advice.

I want to add the part the post skips. Reading the source helps. But the real teacher is a crash dashboard fed by ten thousand cheap phones. And there’s a second gap on top of the first one: almost everything written about Android performance quietly assumes a Pixel. The phones my users actually hold are 1 to 2 GB ARMv7 devices on a 2G signal, and on those, the “rare edge case” ANR is just an ordinary Tuesday.

Yembi reads mobile money SMS and turns them into a real financial app for users in Burkina Faso. Last week I shipped a pass that cleared out a whole family of ANR and crash clusters from Crashlytics. Here’s what was actually causing them, because none of it looked like the examples in the tutorials.

The broadcast that paid for a screen it never showed

Yembi wakes up when an operator SMS arrives. That’s a BroadcastReceiver, and a broadcast can cold-start the whole process. The problem: my app startup did the full warmup every time the process came to life, including the work that only makes sense when a human is about to look at a screen.

So a transaction SMS landing at 6 a.m. would cold-start the process, fire the entire foreground warmup, and on a low-end device all of that happened before anything was even drawn. Pure background work, paying the full price of a foreground launch. On a fast phone you never notice. On a 1 GB device it’s an ANR.

The fix was to ask why the process woke up before doing anything. Startup now checks process importance and gates the UI-oriented init behind it. If we woke up to handle a broadcast, we do the broadcast and nothing else; the rest is deferred to the first real foreground. The lesson that generalises: your Application.onCreate runs for reasons that have nothing to do with your UI, so it shouldn’t behave as if a user is always waiting.

The ANR that was the compiler, not my code

This one had a stack trace pointing at OpenDexFilesFromOat, which is class loading. On ARMv7 hardware, cold-start class loading can fall back to JIT compiling on the main thread, and that’s slow enough to trip the ANR watchdog before my code has really done anything wrong.

The fix isn’t in my code at all. Adding androidx.profileinstaller ships a baseline profile that warms the ART compiler on first run, so the hot startup path is AOT compiled instead of JIT’d while the user waits. One dependency and a profile, and a class of ANRs I couldn’t have solved by writing better Kotlin simply stopped appearing. This is where the read-the-source advice earns its keep: the problem lived below my code, so the fix did too.

The system API that was IPC in a loop

Importing a user’s SMS history means walking thousands of rows. For each row I needed to know which SIM it came in on, so I called SubscriptionManager.getActiveSubscriptionInfo.

That call looks free. It isn’t. It’s a binder transaction, an IPC across a process boundary, and I was making one per SMS row. On a big import that’s a binder storm: thousands of cross-process round trips back to back, which on a constrained device shows up as both OOM pressure and ANRs. The fix was small once I saw it: cache the lookup per subscription id, because the answer doesn’t change from row to row. The general trap is that plenty of innocent-looking Android APIs are IPC in disguise, and IPC in a tight loop behaves nothing like a method call.

Keystore and an encrypted database on the main thread

A few more ANRs traced back to startup touching things that do real disk and crypto work: opening the SQLCipher-encrypted database, and a Crashlytics device-fingerprint read that went through the Keystore. Both on the main thread, both fine on a fast phone, both an ANR on a slow one with cold storage.

The fix was to force the encrypted DB open on an IO thread at startup and move the fingerprint read off the main thread. Nothing clever, just the discipline of treating every disk and Keystore touch as slow until proven otherwise, because on the hardware my users have, it is.

When your crash reporting becomes the crash

Not an ANR, but worth telling. My SIM diagnostics logged a non-fatal every time a certain event fired, and that event fired constantly. Crashlytics ended up with 27,000+ copies of the same event, which buries the signal you actually need and isn’t free to collect either. I throttled it to one report per event per six hours. A dashboard you can’t read is just a slower outage.

The part I’m keeping: a guardrail so it doesn’t come back

Fixing these once is satisfying and completely temporary. The same mistake will happen again, including by me, six months from now: someone adds a blocking disk, Keystore, binder or DB call on the main thread.

So the last thing in the pass wasn’t a fix, it was a tripwire: a small MainThreadGuard with an assertOffMain(operation) call you wrap around anything that does blocking IO. In debug builds, calling it on the main thread logs a loud warning with a stack trace, and StrictMode flags the underlying read besides. In release it’s a no-op that never throws, so it can’t turn a latent slow path into a crash for a real user. It doesn’t move work off the main thread for you. It makes “we put blocking work on main again” impossible to miss while you’re still at your desk.

That’s the real takeaway. The individual ANRs were specific to my app. The thing that keeps the dashboard clean is turning each lesson into something that fails loudly in development instead of quietly in a user’s hand.

What the cheap phones taught me

Every one of these was invisible on the device on my desk. None of them looked like a tutorial example. And all of them were obvious once I stopped thinking about the happy path and started thinking about the worst phone, the coldest start, the longest import, the weakest signal. Read the source, yes. But also get your app onto the hardware your users actually own, point a crash dashboard at it, and let the median Tuesday teach you what no tutorial will.