Summary
Every React Native Android app keeps the main-thread Choreographer armed at ~60 doFrames/second while completely idle in the foreground — no timers pending, no animations running, no mount items, zero frames rendered — on every Android version we tested (API 28 → 36). It is pure CPU/battery waste present in stock template apps with zero app code or third-party dependencies involved.
Measured on a stock react-native@0.86.3 template app (npx @react-native-community/cli init --version 0.86.3), Release build, app foregrounded and settled, no interaction:
| Device |
API |
doFrames / 10s idle |
median / p90 / max doFrame |
App process CPU |
Frames rendered (gfxinfo) |
| OnePlus 3T (SD820) |
28 |
597–598 |
1.95 / 2.81 / 15.86 ms |
~22.5% |
0 |
| OnePlus 5T (SD835) |
29 |
599–601 |
0.99–1.82 / 2.11 / 14.67 ms |
~9.3% |
0 |
| OPPO Find X8 (Dimensity 9400) |
36 |
587–601 |
0.67–0.86 / 0.94–1.21 / 2.83 ms |
~3.5–6.8% |
0 |
The loop stops when the app is backgrounded (host pause) and restarts on resume. iOS is unaffected (Choreographer is Android-only).
Root cause
Four Android frame callbacks re-post themselves unconditionally at the end of their own doFrame, and are initially armed at host resume, with no pending-work check:
| # |
Class |
Re-arm site (0.86.3) |
| 1 |
JavaTimerManager$TimerFrameCallback |
JavaTimerManager.kt:317 — posts itself after every frame even with an empty timer queue |
| 2 |
FabricEventDispatcher$ScheduleDispatchFrameCallback |
FabricEventDispatcher.kt:153 — doFrame re-posts unless stopped; events are actually dispatched synchronously in dispatchEvent, this callback only notifies BatchEventDispatchedListeners |
| 3 |
NativeAnimatedModule$animatedFrameCallback$1 |
NativeAnimatedModule.kt:353 — enqueueFrameCallback() outside the hasActiveAnimations() guard (the guard protects the work, not the re-arm) |
| 4 |
FabricUIManager$DispatchUIFrameCallback |
FabricUIManager.java:1666 — finally { schedule(); } re-schedules even when all queues drained |
Runtime attribution: replacing ReactChoreographer with a logging build (log POST/RUN/REMOVE with callback class names) shows, in a 10s idle window on the stock template:
604 RUN type=NATIVE_ANIMATED_MODULE cb=com.facebook.react.animated.NativeAnimatedModule$animatedFrameCallback$1
604 RUN type=DISPATCH_UI cb=com.facebook.react.fabric.FabricUIManager$DispatchUIFrameCallback
603 POST type=NATIVE_ANIMATED_MODULE cb=com.facebook.react.animated.NativeAnimatedModule$animatedFrameCallback$1
603 POST type=DISPATCH_UI cb=com.facebook.react.fabric.FabricUIManager$DispatchUIFrameCallback
with the timers/dispatcher callbacks cycling the same way on stock builds (source-verified).
Reproduction
npx @react-native-community/cli@latest init repro --version 0.86.3 --skip-git-init
cd repro/android && ./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk
adb shell am start -W -n com.repro/com.repro.MainActivity
# settle ≥40s, then:
adb shell atrace -t 10 -b 32768 view input -z -o /data/local/tmp/idle.atrace.gz
adb pull /data/local/tmp/idle.atrace.gz .
# → ≈600 "Choreographer#doFrame" sections for the app pid, each containing only
# an empty "animation" stage; Android 16 also exposes DoFrameCB-IsEmptyDoFrame=1
adb shell dumpsys gfxinfo com.repro reset && sleep 10 \
&& adb shell dumpsys gfxinfo com.repro | grep "Total frames rendered" # → 0
Control: a raw Java Activity with setContentView(new View(this)) and zero dependencies receives 0 doFrames at idle — the OS floor is clean; this is entirely framework-driven.
Validation of the fix direction
Patched-framework experiments on the stock template (Find X8):
Proposed fix
Make each pump re-arm only when it has work, re-arming lazily from its registration path (PR to follow):
TimerFrameCallback.doFrame: re-post only if (timers.isNotEmpty()); re-arm from createTimer.
ScheduleDispatchFrameCallback.doFrame: never re-post (one-shot per schedule request).
animatedFrameCallback.doFrameGuarded: enqueueFrameCallback() inside the hasActiveAnimations() branch; re-arm in didDispatchMountItems after operation batches execute (startAnimatingNode et al.).
DispatchUIFrameCallback.doFrameGuarded: re-schedule only while mount items remain pending.
The newer subsystems (AnimationBackend, EventBeat) already follow this demand-gated pattern — these four are the legacy stragglers.
Impact
Every RN Android app, on every Android version, burns main-thread CPU at vsync rate whenever its screen is merely visible — reading, idling, screen-on standby. Worst on low-end hardware (22.5% of one core measured on a 2016 SoC) where it also competes with real frame work (15.9ms worst-case idle doFrames measured). After the fix, idle RN apps are frame-silent like native apps, with no behavior change whenever work exists.
Summary
Every React Native Android app keeps the main-thread Choreographer armed at ~60 doFrames/second while completely idle in the foreground — no timers pending, no animations running, no mount items, zero frames rendered — on every Android version we tested (API 28 → 36). It is pure CPU/battery waste present in stock template apps with zero app code or third-party dependencies involved.
Measured on a stock
react-native@0.86.3template app (npx @react-native-community/cli init --version 0.86.3), Release build, app foregrounded and settled, no interaction:The loop stops when the app is backgrounded (host pause) and restarts on resume. iOS is unaffected (
Choreographeris Android-only).Root cause
Four Android frame callbacks re-post themselves unconditionally at the end of their own
doFrame, and are initially armed at host resume, with no pending-work check:JavaTimerManager$TimerFrameCallbackJavaTimerManager.kt:317— posts itself after every frame even with an empty timer queueFabricEventDispatcher$ScheduleDispatchFrameCallbackFabricEventDispatcher.kt:153—doFramere-posts unless stopped; events are actually dispatched synchronously indispatchEvent, this callback only notifiesBatchEventDispatchedListenersNativeAnimatedModule$animatedFrameCallback$1NativeAnimatedModule.kt:353—enqueueFrameCallback()outside thehasActiveAnimations()guard (the guard protects the work, not the re-arm)FabricUIManager$DispatchUIFrameCallbackFabricUIManager.java:1666—finally { schedule(); }re-schedules even when all queues drainedRuntime attribution: replacing
ReactChoreographerwith a logging build (log POST/RUN/REMOVE with callback class names) shows, in a 10s idle window on the stock template:with the timers/dispatcher callbacks cycling the same way on stock builds (source-verified).
Reproduction
Control: a raw Java
ActivitywithsetContentView(new View(this))and zero dependencies receives 0 doFrames at idle — the OS floor is clean; this is entirely framework-driven.Validation of the fix direction
Patched-framework experiments on the stock template (Find X8):
pthread.hnot found #2 (re-arm only when work exists): app fully functional, but the loop persists (pumps docs(README): fix quickstart instruction #3/Set UIStatusBarStyleLightContent #4 still active) — proving each pump independently keeps the Choreographer armed.Proposed fix
Make each pump re-arm only when it has work, re-arming lazily from its registration path (PR to follow):
TimerFrameCallback.doFrame: re-post onlyif (timers.isNotEmpty()); re-arm fromcreateTimer.ScheduleDispatchFrameCallback.doFrame: never re-post (one-shot per schedule request).animatedFrameCallback.doFrameGuarded:enqueueFrameCallback()inside thehasActiveAnimations()branch; re-arm indidDispatchMountItemsafter operation batches execute (startAnimatingNode et al.).DispatchUIFrameCallback.doFrameGuarded: re-schedule only while mount items remain pending.The newer subsystems (
AnimationBackend,EventBeat) already follow this demand-gated pattern — these four are the legacy stragglers.Impact
Every RN Android app, on every Android version, burns main-thread CPU at vsync rate whenever its screen is merely visible — reading, idling, screen-on standby. Worst on low-end hardware (22.5% of one core measured on a 2016 SoC) where it also competes with real frame work (15.9ms worst-case idle doFrames measured). After the fix, idle RN apps are frame-silent like native apps, with no behavior change whenever work exists.