Repository navigation
[Android] Idle apps keep the Choreographer running at ~60 doFrames/s (zero work, zero frames rendered) — four frame callbacks re-arm unconditionally #58367
Description
Activity
- addedNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.This issue could be improved with a clear list of steps to reproduce the issue.
on Sep 6, 2026 Warning
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
- For majority of bugs: send us a Pull Request with the RNTesterPlayground.js edited to reproduce your bug.
- If your bug is UI related: a Snack
- If your bug is build/upgrade related: a project using our Reproducer Template
You can read more about about it on our website: How to report a bug.- addedNeeds: AttentionIssues where the author has responded to feedback.Issues where the author has responded to feedback.and removed
on Sep 7, 2026 Thanks @javache, really useful pointers. I went through all four diffs and compared them against the four PRs linked from this issue. Here is what I found, plus one question at the end.
Why our search missed these
Our PR descriptions claim a prior-art search came up empty. These four escaped it: we searched current
mainand open PRs, but the experiment was fully reverted by February 2024, so there was nothing left onmainto find. We should have grepped release branches for the flag name too.Timeline of
enableOnDemandReactChoreographerWhen Diff What it did Outcome Nov 2023 #41606 NativeAnimatedModule: skip theNATIVE_ANIMATED_MODULEre-post whenhasActiveAnimations()is falseLanded, then backed out Nov 2023 #41658 FabricEventDispatcher: one-shot dispatch callback, no self re-postLanded, later removed Nov 2023 #41671 Backout of #41606, days after it landed "caused performace problems with react app" Feb 2024 #43044 Removed the flag and the remaining on-demand paths in FabricUIManager,MountItemDispatcher,FabricEventDispatcherExperiment abandoned What I think broke in #41606
The gate was
hasActiveAnimations(), but the only re-arm trigger that diff added wasOP_CODE_START_ANIMATING_NODEinside the Java operations loop. Anything producing animation work outside that single op-code had no way to wake the callback back up, for example:- animations driven through the C++ shared animated backend, where the Java
nodesManagermay report no active animations - event-driven animation registrations and value updates from
Animated.event - anything started in the window after a frame observed an empty queue and disarmed the callback
If the internal app runs the C++ backend, the first bullet would stall or freeze animations there while OSS JS-backend apps look fine, which is consistent with "performance problems with react app". Is that close to what actually happened, or was the failure something else? This also lines up with the open question for @zeyap on #58377, and separately Reanimated parks its worklet runloop on this same slot.
How the four PRs here differ
The design rule we followed: the code path that submits work is the same code path that re-arms the callback, on every entry point.
PR Slot Disarm condition Re-arm coverage #58375 TIMERS_EVENTStimer queue drained every createTimer, with a UI-thread hop#58376 event dispatch one-shot by design every event enqueue via the existing dispatch path #58377 NATIVE_ANIMATED_MODULEhasActiveAnimations()falseevery addOperation,addUnbatchedOperation,addPreOperation, plus after each executed batch#58378 DISPATCH_UIno pending mount items every MountItemDispatcherenqueue viaonItemsQueuedFor #58378 specifically: the old
FabricUIManager.postChoreographerCallbackIfNecessaryarmed from wrapper methods onFabricUIManager; this PR hooks the dispatcher itself, which every enqueue path must pass through, including ones that bypass those wrappers. The old code also carried a comment warning that double-scheduling could deadlock with LayoutAnimations; the single-schedule invariant is preserved through the existing idempotentschedule().Validation so far
The loop being targeted, measured stock on three device tiers at idle:
Device Idle doFrames per 10s Idle CPU Frames rendered OnePlus 3T, SD820 597 to 598 ~22.5% 0 OnePlus 5T, SD835 599 to 601 ~9.3% 0 OPPO Find X8, Dimensity 9400 587 to 601 ~3.3 to 6.8% 0 Flag-on builds of all four, backported to 0.86.3 and soaked on the 3T in foreground and background: three of the four slots fully disarmed at idle (the app keeps live timers, so
TIMERS_EVENTScorrectly stays armed), and timers, events, animations and mounts were all serviced with no starvation.Asks
- If anyone remembers the concrete failure mode from the internal rollout, we will build a targeted repro for exactly that before pushing these further.
- If the C++ backend makes the animation slot permanently unsafe to gate, the other three slots are independent of it and we would still welcome a review of those.
- animations driven through the C++ shared animated backend, where the Java
Self-review follow-up on the analysis above
Reading the old
FabricUIManagercondition (hasMountItems() || mDriveCxxAnimations) against currentmainexposed a real gap in one of our own PRs.The bug:
- Android: stop re-arming the idle DISPATCH_UI Choreographer frame callback (behind feature flag) #58378 initially dropped the
mDriveCxxAnimationsterm - that would starve C++ animation sessions (LayoutAnimations, the shared animation backend) on frames with no pending mount items
The fix:
- d45be0e restores the equivalent condition
- plus a re-arm in
onAnimationStartedfor sessions that begin while idle
In other words, reading the old experiment's code caught a live bug in our port before any reviewer had to.
- Android: stop re-arming the idle DISPATCH_UI Choreographer frame callback (behind feature flag) #58378 initially dropped the
- added a commit that references this issue
on Sep 28, 2026
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.