The ANR you never see, on the phones you never test
Tutorials assume a Pixel. My users hold 1GB ARMv7 phones on 2G, where the rare ANR is the median experience. Here is a real production ANR/OOM pass from Yembi, and the third gap nobody talks about.
Tutorials assume a Pixel. So does most of the AOSP advice that gets handed out when the tutorials run out. You learn Kotlin, Compose, Jetpack, and then production hits you with ANRs, OOM kills, and jank, and the standard answer is to go read the AOSP source. Good advice. The answers really are in there.
But it all quietly assumes a fast phone on a good network. The phones my users actually hold are 1GB to 2GB ARMv7 devices, several Android versions behind, on a connection that is a 2G event rather than a state. On those phones the ANR that the tutorial calls a rare edge case is the median experience. Reading the source helps. The real teacher is Crashlytics reporting from ten thousand cheap phones at once.
I lead Yembi at DOKAL Technologies. Last week I shipped a pass that cleared a wall of ANR and crash clusters off our Crashlytics. Here is what was actually breaking, and why each one only shows up at the bottom of the device market.
A broadcast can wake your whole app for nothing
Yembi reads mobile money SMS, so an incoming message fires a broadcast. On a cheap phone where the process had been killed to reclaim memory, that broadcast was cold-starting the entire app and triggering the full foreground warmup, the whole herd of initialization, just to handle one text message in the background.
The phone woke up for a background reason and was billed for a foreground startup. On a Pixel you would never notice. On a 1GB phone it is a cold-start ANR.
The fix is to gate the UI-oriented startup behind process importance and defer it to the first real foreground. A process is allowed to wake quietly. It should not pay full price every time it does.
The classloader ANR that only exists on ARMv7
The second cluster was an OpenDexFilesFromOat ANR, concentrated almost entirely on ARMv7. On first run, the ART compiler was loading and JIT-compiling classes on the main thread while the user waited.
The fix was androidx.profileinstaller with a bundled baseline profile, so the critical classes are ahead-of-time compiled on first launch instead of compiled on the main thread the moment the user opens the app. This is a problem you can only find on the hardware where it happens. On a fast 64-bit phone, the JIT is quick enough that the ANR never trips.
The system call that is secretly a round trip
This one is my favorite, because it looks completely innocent.
During a large SMS import, the code called SubscriptionManager.getActiveSubscriptionInfo once per message, to route each one to the right SIM. Reads like a field access. You would not think twice about it in a loop.
Except it is not a field access. It is a binder IPC: a round trip out to a system process, across a process boundary, and back. One call is cheap. The cost is invisible until you put it where I put it, which was inside a loop over thousands of imported messages. Every row paid for a full IPC. On a phone with headroom, the kernel and the system server absorb the churn and you never notice. On a 1GB device mid-import, it became a binder storm: thousands of round trips hammering the system server, the binder thread pool saturating, the UI thread eventually blocking on its turn, and an OOM or ANR with a stack trace that points at nothing obvious.
The fix is boring. The active subscription does not change mid-import, so I read it once and cache it per subscription ID. Thousands of system calls collapsed to a handful. The lesson is the one I keep relearning: a cheap-looking system API in a loop is rarely a field read. Somewhere under the method name there is usually IPC, disk, or a lock, and on a small phone the bill arrives the same day instead of next year.
Two more, off the main thread where they belong
Two smaller clusters had the same root cause: slow, blocking work sitting on the thread that draws the UI.
At startup, opening the encrypted database and reading the keystore was happening on the main thread, with a Crashlytics device-fingerprint read riding along in the same path. Decryption, plus a keystore round trip, plus corruption-recovery checks, add up to a startup ANR on a slow device before the first screen ever draws. Forcing the database open on an IO thread and moving the fingerprint read off-main cleared it. Nothing clever, just refusing to block the UI thread with work that does not belong there.
The other was self-inflicted noise: one diagnostic firing 27,000 identical non-fatal events. That is not observability, it is a fog that hides the signal you actually need and burns battery and quota producing it. Throttled to one event per type per six hours. A log you cannot read is worse than no log.
The reusable part: make the mistake loud in development
The fixes above are specific. The thing I keep is general. I added a MainThreadGuard and turned on StrictMode in debug. Call assertOffMain on a path that should never block the UI thread, and in a debug build it logs a loud warning with a full stack trace the moment someone violates it. In release it is a cheap no-op that never throws, because a guard that crashes users in production is worse than the bug it guards against.
It does not move work off the main thread for you. It makes “someone just added a blocking call to the main thread again” impossible to miss while you still have time to fix it.
The floor under the floor
Everyone talks about the device gap: the phones people actually hold sit far below the test bench, and connectivity is an occasional event rather than a guarantee. True, and worth saying. But there is one more layer I rarely hear mentioned.
Call it the knowledge floor. In the market I build for, most developers have never had to learn what an ANR is. Not because they are less capable, but because they have never watched their own app freeze on the hardware their users own. You cannot debug a problem you never see, and you cannot see this one from a fast phone on good wifi.
So this is just where the conversation has reached locally. Cheap phones, weak networks, and a failure mode that does not have a name here yet. The receipts in this post come from a place the discussion has not gotten to.
The tutorials are not wrong. They are written for a phone most of my users will never hold.
If you build for the bottom of the device market, I would like to hear what your Crashlytics looks like. That is the conversation I actually want.