Skip to content

fix(Worklets): keep AnimationFrameQueue paused until the host resumes - #10914

Open
hirvesh wants to merge 1 commit into
software-mansion:mainfrom
hirvesh:fix/worklets-start-frame-queue-paused
Open

hirvesh wants to merge 1 commit into
software-mansion:mainfrom
hirvesh:fix/worklets-start-frame-queue-paused

Conversation

@hirvesh

@hirvesh hirvesh commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Note

This pull request was authored by AI on behalf of @hirvesh.

Summary

Follow-up to #10196.

The UI runtime's frame loop requests the next frame on every frame (#8900), and #10196 stops it while the app is in the background by pausing AnimationFrameQueue in onHostPause. But the queue starts unpaused, and onHostPause only comes after an onHostResume. When React starts without an Activity, for example for a headless JS task (HeadlessJsTaskService, or the headless loader expo-task-manager uses for background tasks), the host is never resumed, so the queue is never paused either. From the moment the UI runtime is set up, the main thread runs AnimationFrameCallback.onAnimationFrame on every vsync until the process is frozen or killed, with nothing on screen.

We found this through a background ANR in production (react-native-worklets 0.13.0, React Native 0.88). The process had no visible Activity, and the main thread was in a Worklets frame callback:

main (process state: BACKGROUND)
art::ConditionVariable::WaitHoldingLocks
artJniMethodEnd
com.swmansion.worklets.runloop.AnimationFrameCallback.onAnimationFrame
com.swmansion.worklets.runloop.AnimationFrameQueue.executeQueue (AnimationFrameQueue.kt:125)
com.swmansion.worklets.runloop.AnimationFrameQueue$mChoreographerCallback$1.doFrameGuarded (AnimationFrameQueue.kt:23)
com.facebook.react.uimanager.GuardedFrameCallback.doFrame (GuardedFrameCallback.kt:42)
com.facebook.react.modules.core.ReactChoreographer.frameCallback$lambda$1 (ReactChoreographer.kt:58)
android.view.Choreographer.doFrame

The stack doesn't show that the loop caused the ANR: the thread is waiting for a runtime-wide suspension to end. But a frame callback shouldn't be running in that process at all.

This PR starts AnimationFrameQueue paused:

  • Normal launches are unchanged. WorkletsModule.initialize() registers its lifecycle listener, and ReactContext.addLifecycleEventListener calls onHostResume right away when the context is already resumed. If the module initializes before the Activity resumes, the loop starts on the first onHostResume.
  • Nothing is lost while paused. Frame requests made while paused stay in the queue and run on resume, as they already do after onHostPause.
  • It matches React Native. JavaTimerManager starts paused the same way (isPaused = AtomicBoolean(true)).

Test plan

  • I applied this change to our app (react-native-worklets 0.13.0, React Native 0.88.0-rc.3, New Architecture) through a Yarn patch. AnimationFrameQueue.kt is byte-identical on main and in 0.13.0. compileReleaseKotlin passes, and the compiled class initializes mPaused to true.
  • I measured it on a Samsung Galaxy A35 (Android 16) with a release build of the app, signed for profiling, built twice: once with this change and once without it.
    • Setup: I killed the app process and let it restart in the background with no Activity, then force-ran an expo-background-task job (adb shell cmd jobscheduler run -f -n androidx.work.systemjobscheduler <package> <job id>). React starts headless through expo-task-manager, runs the task and is torn down 2 s after the task finishes.
    • Without this change: the app's main thread ran a Choreographer#doFrame on every vsync (60 Hz). This went on from the moment the UI runtime was set up until React was torn down: 241 frames in about 4 s. Sampled stacks show AnimationFrameQueue.executeQueue → AnimationFrameCallback.onAnimationFrame → WorkletRuntime::runSyncAndDiscard. The main thread ran for 344 ms.
    • With it: there were 50 frames, all while the task's JS timers were pending, and the sampled stacks show JavaTimerManager$TimerFrameCallback, not Worklets. The main thread ran for 90 ms.
    • Normal launch: I then opened the app normally with this change. Over 10 s on the home screen, the main thread's sampled stacks hit AnimationFrameQueue.executeQueue in 27 samples (638 frames), so the loop starts as before once the Activity resumes.
  • To reproduce: Perfetto with sched and the view atrace category, plus linux.perf callstack sampling. Start sampling after the process exists: traced_perf doesn't unwind a process that starts after the trace begins.

Changelog

  • I added a changelog fragment with yarn changelog:add for each changed package, or this PR does not change react-native-reanimated or react-native-worklets.

The UI runtime loop re-arms on every frame, and software-mansion#10196 pauses it in
onHostPause. The queue started unpaused, so when React starts without an
Activity (a headless JS task), the host never resumes or pauses and the
loop runs on every vsync until the process is frozen or killed.

Start the queue paused. ReactContext.addLifecycleEventListener calls
onHostResume right away when the app is already resumed, so normal
launches start the loop as before. JavaTimerManager starts paused the
same way.
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 844ba1e3-8c47-4e1a-a37d-3026c63801d9
📥 Commits

Reviewing files that changed from the base of the PR and between 1d2b213 and 3ecc75f.

📒 Files selected for processing: 2
  • packages/react-native-worklets/android/src/main/java/com/swmansion/worklets/runloop/AnimationFrameQueue.kt
  • packages/react-native-worklets/changelog/start-frame-queue-paused.fix.md

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📝 Walkthrough

The Android AnimationFrameQueue now initializes with mPaused set to true. Frame callbacks are not scheduled until resume() clears the paused state. A changelog entry describes the frame-loop issue when React starts without an Activity, including in a headless JS task.

Priority: ➖ Normal

Change: Bug fix · Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 3ecc7

No actionable issue with the paused queue is established. The reported Android lint failure still needs its offending file identified, but it does not currently establish a reason to block this change.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: keeping AnimationFrameQueue paused until the host resumes.
Description check Passed The description is directly related to the changeset. It explains the headless-start issue, the paused queue behavior, expected lifecycle behavior, and test results.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant