react / react/react-native

[Android] Idle apps keep the Choreographer running at ~60 doFrames/s (zero work, zero frames rendered) — four frame callbacks re-arm unconditionally

Open
#58,367 5 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Needs: Attention Needs: Repro
Dominant language
C++
Stars
127k
Forks
25.3k
Avg merge
1d 23h
Merged PRs (30d)
4

Description

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:153doFrame re-posts unless stopped; events are actually dispatched synchronously in dispatchEvent, this callback only notifies BatchEventDispatchedListeners
3 NativeAnimatedModule$animatedFrameCallback$1 NativeAnimatedModule.kt:353enqueueFrameCallback() outside the hasActiveAnimations() guard (the guard protects the work, not the re-arm)
4 FabricUIManager$DispatchUIFrameCallback FabricUIManager.java:1666finally { 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):

  • Demand-gating #1 and #2 (re-arm only when work exists): app fully functional, but the loop persists (pumps #3/#4 still active) — proving each pump independently keeps the Choreographer armed.
  • Gating #3 and #4 as well: 0 doFrames at idle, ~0% CPU, app alive and foreground — the loop fully collapses (notably, exactly one dropped re-post per pump is enough to kill each permanently: each callback was keeping only itself alive).

Proposed fix

Make each pump re-arm only when it has work, re-arming lazily from its registration path (PR to follow):

  1. TimerFrameCallback.doFrame: re-post only if (timers.isNotEmpty()); re-arm from createTimer.
  2. ScheduleDispatchFrameCallback.doFrame: never re-post (one-shot per schedule request).
  3. animatedFrameCallback.doFrameGuarded: enqueueFrameCallback() inside the hasActiveAnimations() branch; re-arm in didDispatchMountItems after operation batches execute (startAnimatingNode et al.).
  4. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the four re-arm sites named in JavaTimerManager.kt, FabricEventDispatcher.kt, NativeAnimatedModule.kt, and FabricUIManager.java, then run the supplied Android reproduction and idle tracing commands. Verify that idle apps produce zero Choreographer doFrames while timers, events, animations, and mount work still operate when present.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, java, kotlin, react-native
Domain
mobile, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.