Skip to content

fix(android): stop re-arming idle Choreographer frame callbacks at vsync rate - #58368

Closed
capt-muji wants to merge 1 commit into
react:mainfrom
capt-muji:fix/android-idle-choreographer-pumps
Closed

fix(android): stop re-arming idle Choreographer frame callbacks at vsync rate#58368
capt-muji wants to merge 1 commit into
react:mainfrom
capt-muji:fix/android-idle-choreographer-pumps

Conversation

@capt-muji

@capt-muji capt-muji commented Sep 6, 2026

Copy link
Copy Markdown

Summary

Resolves #58367.

Four Android frame callbacks re-post themselves unconditionally at the end of their own doFrame (armed at host resume), keeping the main-thread Choreographer running at ~60fps while an app is idle in the foreground with zero pending work and zero frames rendered. Measured on a stock 0.86.3 template app: ~600 doFrames/10s at 0.2–2ms each, ~3.5–22.5% process CPU across API 28–36 devices, Total frames rendered: 0 (full data in the issue).

This makes each pump re-arm only when it has work, re-arming lazily from its registration/posting path:

  1. JavaTimerManager.TimerFrameCallback — re-post only while the timer queue is non-empty; createTimer re-arms when a new timer arrives (posting is thread-safe; ReactChoreographer documents any-thread use, and the flag dance stays under timerGuard).
  2. FabricEventDispatcher.ScheduleDispatchFrameCallback — one-shot per schedule request. Events are dispatched synchronously in dispatchEvent; this callback only notifies BatchEventDispatchedListeners, so doFrame has nothing to re-post. stop() now simply skips listener notification (the stop/resume of the re-post chain is no longer needed).
  3. NativeAnimatedModule.animatedFrameCallbackenqueueFrameCallback() moves inside the existing hasActiveAnimations() branch; didDispatchMountItems re-arms after operation batches execute (where startAnimatingNode-style operations flip hasActiveAnimations() true).
  4. FabricUIManager.DispatchUIFrameCallback — the finally { schedule(); } re-schedules only while MountItemDispatcher still has pending items (new hasPendingItems(); all three queues checked). Posting new items already calls schedule() directly, and the pre-mount "wait until next frame" continuation case is covered because the items are still queued.

Test plan

  • On-device validation with patched framework classes (stock 0.86.3 template, Release, OPPO Find X8 / Android 16): demand-gating nil #1/pthread.h not found #2 keeps the app fully functional (timers fire, events dispatch) but the loop persists via docs(README): fix quickstart instruction #3/Set UIStatusBarStyleLightContent #4; gating all four → 0 doFrames at idle, ~0% CPU, app alive, rendering and interaction normal — each pump was keeping only itself armed (one dropped re-post per pump collapses it permanently).
  • RN CI unit tests (JavaTimerManager/NativeAnimated/Fabric suites) — particularly timers-on-idle semantics, headless-task timer arming, and animation start while disarmed.
  • Manual: animation-heavy screen, requestIdleCallback usage, and setInterval behavior before/after.

Fresh template + atrace/dumpsys gfxinfo reproduction commands are in the linked issue.

Impact

Every RN Android app currently burns main-thread CPU at vsync rate whenever its screen is visible but idle (measured up to ~22.5% of a core on a 2016 SoC, ~3.5% on a 2025 flagship). After this change idle RN apps are Choreographer-silent like native apps. No behavior change while work is pending — each callback keeps its exact per-frame dispatch whenever it has work.

Changelog:

[Android] [Fixed] - Stop re-arming idle Choreographer frame callbacks at vsync rate — idle-foreground apps no longer burn main-thread CPU at ~60 doFrames/s with zero pending work (timers, event dispatch, native animations, mount dispatch each re-arm only when they have work).

…ync rate

Four Android frame callbacks re-post themselves unconditionally on every
doFrame, keeping the Choreographer armed at ~60fps while an app is
foreground-idle with zero pending work (zero timers, zero animations, zero
mount items, zero frames rendered):

- JavaTimerManager.TimerFrameCallback (JavaTimerManager.kt)
- FabricEventDispatcher.ScheduleDispatchFrameCallback
- NativeAnimatedModule.animatedFrameCallback
- FabricUIManager.DispatchUIFrameCallback

Each now re-arms only when it has work, and re-arms lazily from its
registration/posting path (createTimer / didDispatchMountItems / schedule).
Measured on a stock RN 0.86.3 template app, idle foreground: ~600
doFrames/10s at 0.2-2ms each and 3.5-22.5% process CPU across API 28-36
devices; after this change: 0 doFrames, ~0% CPU, app fully functional.
@meta-cla

meta-cla Bot commented Sep 6, 2026

Copy link
Copy Markdown

Hi @capt-muji!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla

meta-cla Bot commented Sep 7, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 7, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 7, 2026
@javache

javache commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

We looked at improving some of these in the past, but it's very easy to cause regressions here, especially when foregrounding/background. Can you split this PR into the different changes, and add feature flags so we can control the rollout?

@capt-muji

Copy link
Copy Markdown
Author

Thanks @javache — that makes sense. Plan: split this into per-pump PRs (JavaTimerManager timers, FabricEventDispatcher, NativeAnimatedModule, FabricUIManager/MountItemDispatcher), each introducing its own ReactNativeFeatureFlags flag that defaults to the current behavior so the rollout of each change can be controlled independently. We'll link the split PRs here and close this one once they're up. The foregrounding/backgrounding paths (onHostPause/Resume re-arm, choreographer teardown) are untouched in this diff — we'll make that explicit per-PR and re-verified on the pause/resume path.

@capt-muji

Copy link
Copy Markdown
Author

Split complete per review — closing in favor of: #58375 (JavaTimerManager / TIMERS_EVENTS), #58376 (FabricEventDispatcher one-shot), #58377 (NativeAnimatedModule / NATIVE_ANIMATED_MODULE), #58378 (FabricUIManager / DISPATCH_UI + re-arm on item queue). Each is behind its own ReactNativeFeatureFlags flag defaulting to current behavior (ossReleaseStage: experimental). Beyond the original diff, the split versions add the re-arm-trigger completeness points identified during the split: timers re-arm covers headless-JS execution while the host is paused (#58375), native-animated re-arms on operation enqueue so imperative JS animation starts can't starve without pending mount items (#58377), and mount-item queueing from any thread re-arms DISPATCH_UI so off-UI-thread view commands can't starve at idle (#58378).

@capt-muji capt-muji closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants