◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-06-01Advanced

When Your Android Vitals ANR Rate Climbs: Notes on Keeping the Main Thread Free in React Native

How I traced and fixed a rising Android Vitals ANR rate in a Rork-built React Native app, plus the main-thread rules I rolled out across six apps in production.

React Native239Android55ANRPerformance25Crashlytics12

✦ Premium Article

One morning a Google Play Console alert caught my eye. Out of the six wallpaper apps I run in parallel (such as Ukiyo-e Wallpapers), exactly one had its Android Vitals "ANR rate" climb to 0.61%, crossing Play's bad-behavior threshold of 0.47% that can affect store visibility. Crashes weren't up. The crash-free rate was holding at 99.6%. And yet users were experiencing something like "it freezes, goes black after a while, then closes on its own."

ANR (Application Not Responding) is a separate signal from crashes. For my first few years I watched crashes almost exclusively and never monitored ANRs properly. Across more than 50 million cumulative downloads since I started building apps on my own in 2014, it was only in the last two or three years that I really felt how nasty ANRs are: the feature works correctly, but the experience is broken. This article is the record of how I traced that one app, and the "keep the main thread free" design rules I then spread to all six.

ANR Is Not "Slow JS" — It Is "the UI Thread Stopped Responding"

When people think about React Native performance, many of us reach first for the weight of the JS thread. I thought that way for a long time. But Android's ANR is, strictly speaking, not about the JavaScript thread.

Android records an ANR mainly in these situations: an input event doesn't get a response within about five seconds, a BroadcastReceiver doesn't finish in time, or a Service doesn't complete within its window. In every case, what's being judged is the main thread (the UI thread). In React Native, JS runs on its own thread, but synchronous native module calls, large data crossing the bridge, native initialization at startup, and image decoding all ultimately show up as work on the main thread. When that gets jammed, you get an ANR no matter how light your JS is.

So reducing ANRs is not "make JS faster." It is "find the spots where the main thread is blocked continuously for a long time, and clear them." Shifting my view to this point was the first breakthrough.

One thing worth adding: since Android 11, ANR accounting has widened to include things like delays in Context.startForegroundService, not just broadcasts and input events. A point that's surprisingly easy to miss in a React Native app is the native initialization that third-party SDKs (ads, analytics, push) run at startup. My six apps all include AdMob, so whether the ad SDK's initialization overlaps with synchronous startup work is something I check every time. If you only suspect your own JS, you'll get lost when the cause is on the SDK side.

Pin Down the Location With Crashlytics and Android Vitals

I narrowed down where it was happening by squeezing it from two directions.

One is Google Play Console's Android Vitals. ANRs are grouped into "clusters" with stack traces. For the problem app, roughly 70% of the top cluster originated from nativeRunnable, and they all concentrated in the first few seconds after launch. That alone points to "somewhere in the startup sequence."

The other is Firebase Crashlytics. Lately, when I update an Android app, I have Claude in Chrome read the Crashlytics screen and organize the correlation between ANR cluster stack traces and the version they appeared in. Having it summarize the on-screen tables — "which release did this start growing from," "which devices and OS versions are over-represented" — lets me narrow my hypothesis before I open any code. This time it skewed toward Android 12 and up on lower-end devices, which read as "surfacing on devices where the synchronous startup work is slow."

My grandfather, a temple carpenter, used to say that "working with your hands is a kind of faith," and ANR investigation is exactly that: measuring each suspicious initialization one at a time is far more reliable than staring at logs and guessing.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦How I triaged one app crossing the 0.47% Android Vitals ANR threshold within 72 hours of release
✦Before/After code that moves synchronous startup work off the main thread, with a measured 0.61%→0.12% ANR drop
✦The New Architecture pitfall where synchronous TurboModule calls block the main thread, and the rule I use across six apps
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.
⬡ Dev Tools2026-08-31
Three Minutes After the Screen Locked, My expo-audio Ambient Sound Went Silent
Why expo-audio stops playing when the screen locks, diagnosed as three separate layers: build config, audio session, and lock screen integration. The three-minute stop on Android is documented behavior.
⬡ Dev Tools2026-07-24
Collapsing Duplicate Requests Into One: A Reference-Counted Single-Flight Layer
When several components fire the same API call at launch, you get a burst of identical requests. Here is a single-flight layer that shares one in-flight promise instead: how to build the key that decides the folding, the trap of handing out a failed promise forever, the trap of one caller's abort cancelling everyone, and the test that keeps it all from regressing, with the real network numbers alongside.