Skip to content

Android: stop re-arming the idle TIMERS_EVENTS Choreographer frame callback (behind feature flag) - #58375

Open
capt-muji wants to merge 2 commits into
react:mainfrom
capt-muji:fix/android-idle-timers-choreographer
Open

Android: stop re-arming the idle TIMERS_EVENTS Choreographer frame callback (behind feature flag)#58375
capt-muji wants to merge 2 commits into
react:mainfrom
capt-muji:fix/android-idle-timers-choreographer

Conversation

@capt-muji

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

Copy link
Copy Markdown

Stop re-arming the idle TIMERS_EVENTS Choreographer frame callback (Android)

Part of #58367. Split from #58368 per review feedback (one PR per pump, each behind a feature flag).

Summary

JavaTimerManager.TimerFrameCallback.doFrame re-posts itself unconditionally at the end of every frame, so the TIMERS_EVENTS Choreographer callback runs at vsync rate (~60 doFrame/s) forever — including while the app is idle with an empty timer queue. Each idle frame performs no work and renders nothing; on low-end devices this is a measurable constant CPU cost while an RN app sits still (see #58367 for the full matrix: measured on a OnePlus 3T as ~22% process CPU at idle for the four unconditional pumps combined; every looping frame is a no-op).

This PR makes the re-post demand-gated, behind ReactNativeFeatureFlags.disableIdleTimersFrameCallbackRearmAndroid (default false = current behavior; ossReleaseStage: 'experimental'):

  • doFrame re-posts only while the timer queue is non-empty; when it drains, the callback disarms itself.
  • createTimer re-arms the callback lazily when a new timer arrives. The guard mirrors clearFrameCallback's inverse: while the host is paused, only headless JS task execution may re-arm (!isPaused || isRunningTasks), so the paused-app contract is preserved.

Foregrounding / backgrounding

The existing lifecycle paths are untouched and remain the authoritative arm/disarm points:

  • onHostResume / onHeadlessJsTaskStartsetChoreographerCallback() (re-arms if disarmed).
  • onHostPause / onHostDestroy / onHeadlessJsTaskFinishclearFrameCallback() (unchanged guard: paused + no headless tasks).

While the host is paused with headless tasks running, a drained queue disarms the callback; the next createTimer from the still-running headless JS re-arms it (isRunningTasks is true in that state).

Rollout

Flag: disableIdleTimersFrameCallbackRearmAndroiddefaultValue: false, expectedReleaseValue: true, ossReleaseStage: 'experimental'. With the flag off, behavior is identical to current main.

Test plan

  • Flag off (defaultValue): code path is byte-equivalent to main (shouldRepost stays true; createTimer re-arm branch is skipped).
  • Compiles: ./gradlew :packages:react-native:ReactAndroid:compileReleaseKotlin.
  • Flag on, real app on device (OnePlus 3T, Android 9): idle adb shell atrace shows TIMERS_EVENTS doFrames drop to ~0 while no JS timers are scheduled; timers (countdowns, setTimeout/setInterval apps) still fire; app backgrounded → callback stays disarmed; headless task timers fire while backgrounded; foreground return re-arms.
  • Flag on: RNTester timers integration tests pass.

Changelog:

[Android] [Fixed] - Stop re-arming the TIMERS_EVENTS Choreographer frame callback at vsync rate while the timer queue is empty (behind disableIdleTimersFrameCallbackRearmAndroid)

… Android

TimerFrameCallback.doFrame re-posts itself unconditionally every frame, so the
TIMERS_EVENTS callback runs at vsync rate even while the timer queue is empty.
Make the re-post demand-gated behind
disableIdleTimersFrameCallbackRearmAndroid (default off): doFrame disarms when
the queue drains and createTimer re-arms lazily (headless-task aware, mirroring
clearFrameCallback's guard). Lifecycle arm/disarm points are untouched.
@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
Comment on lines +195 to +198
reactChoreographer.postFrameCallback(
ReactChoreographer.CallbackType.TIMERS_EVENTS,
timerFrameCallback,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now called off the UI thread.

createTimer can run on the native module thread; ReactChoreographer must be
driven from the UI looper (its null-choreographer fallback hop does not cover
the initialized case). Route the lazy re-arm through setChoreographerCallback
on the UI queue, which is idempotent and already used by onHostResume.
@capt-muji

Copy link
Copy Markdown
Author

Good catch — fixed in 198f5ca: createTimer may run on the native module thread and ReactChoreographer.postFrameCallback only auto-hops to the UI thread when the choreographer field is not yet initialized, so the direct post was looper-unsafe. The lazy re-arm now routes through setChoreographerCallback() on the UI queue (idempotent via the frameCallbackPosted guard, same entry point onHostResume uses); the timer is added to the queue before the runnable can run, so the armed callback always sees it.

@capt-muji

capt-muji commented Sep 7, 2026

Copy link
Copy Markdown
Author

On-device validation (OnePlus 3T, Android 9 / API 28, bridgeless Release)

Validated by backporting this PR + the three sibling idle-pump PRs onto v0.86.3 (main's 1000.0.0 is ABI-incompatible with our app's 0.86.3-pinned expo/community modules — safe-area/worklets/nitro broke on main) and forcing disableIdleTimersFrameCallbackRearmAndroid ON, via publishAllToMavenTempLocal + dependencySubstitution into a real production app.

Result: at idle the TIMERS_EVENTS callback stays armed (~40/s) because the app runs a perpetual per-second countdown timer, so the queue is never empty. This is the intended behavior — the bytecode confirms the flag path (isEmpty() → skip re-post) and the pump disarms only when the queue truly drains (verified separately on a timer-idle blank view in the original investigation).

Background soak: onHostPauseisPaused=true → the timer pump stops re-arming entirely (0 posts). On foreground the countdown resumes with no starvation — verified across a full fg→bg→fg cycle (countdown advanced correctly, no frozen ticks).

No regressions across the interaction smoke battery (overlay open/close, settings + alert sheets, pager swipes, launch chrome-defer which is rAF+setTimeout).

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.

2 participants