Skip to content

[Android] Timers do not run when using HeadlessJsTaskService #56324

Description

@gmanole01

Description

Hello!

I noticed that when using HeadlessJsTaskService, the timers do not run as expected. I did some investigations and I found out that React Native uses Android's Choreographer to schedule the timers.
I am testing the app on a real device: Samsung Galaxy S22 Ultra.
Apparently, Samsung's OneUI pauses the Choreographer when the app goes into background.
In order to fix this, React Native should use another technique to get "ticks", like a Handler or something, at least for the timers (I saw UI events are also processed using Choreographer).

Steps to reproduce

  1. Install the application using "yarn android"
  2. Open up Android Studio with Logcat open
  3. Wait for the background service to start
  4. Put the app in foreground by going on the home screen
  5. Look in the logs and wait for the line with "CoreRune.SYSPERF_ACTIVE_APP_BBA_ENABLE : stop animation in background states"

React Native Version

0.84.1

Affected Platforms

Runtime - Android

Output of npx @react-native-community/cli info

System:
  OS: macOS 26.3.1
  CPU: (14) arm64 Apple M3 Max
  Memory: 160.17 MB / 36.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 22.13.1
    path: /Users/george/.nvm/versions/node/v22.13.1/bin/node
  Yarn:
    version: 3.6.4
    path: /Users/george/.nvm/versions/node/v22.13.1/bin/yarn
  npm:
    version: 10.9.2
    path: /Users/george/.nvm/versions/node/v22.13.1/bin/npm
  Watchman:
    version: 2025.05.26.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.16.2
    path: /Users/george/.rvm/gems/ruby-3.0.0/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.0
      - iOS 26.0
      - macOS 26.0
      - tvOS 26.0
      - visionOS 26.0
      - watchOS 26.0
  Android SDK:
    API Levels:
      - "30"
      - "31"
      - "33"
      - "34"
      - "35"
      - "36"
      - "36"
    Build Tools:
      - 30.0.2
      - 30.0.3
      - 33.0.0
      - 34.0.0
      - 35.0.0
      - 36.0.0
      - 36.0.0
    System Images:
      - android-34 | Android TV ARM 64 v8a
      - android-35 | ARM 64 v8a
      - android-35 | Google Play ARM 64 v8a
      - android-35 | Google Play Tablet ARM 64 v8a
      - android-36 | Google Play ARM 64 v8a
      - android-36 | Pre-Release 16 KB Page Size Google Play ARM 64 v8a
    Android NDK: Not Found
IDEs:
  Android Studio: 2025.3 AI-253.30387.90.2532.14935130
  Xcode:
    version: 26.0.1/17A400
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.13
    path: /usr/bin/javac
  Ruby:
    version: 3.0.0
    path: /Users/george/.rvm/rubies/ruby-3.0.0/bin/ruby
npmPackages:
  "@react-native-community/cli":
    installed: 20.1.0
    wanted: 20.1.0
  react:
    installed: 19.2.3
    wanted: 19.2.3
  react-native:
    installed: 0.84.1
    wanted: 0.84.1
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: Not found
  newArchEnabled: false

Stacktrace or Logs

2026-04-04 17:31:59.258 11074-11128 ReactNativeJS           com.choreographerissue               I  Hello from service!
2026-04-04 17:32:00.260 11074-11128 ReactNativeJS           com.choreographerissue               I  Hello from service!
2026-04-04 17:32:01.264 11074-11128 ReactNativeJS           com.choreographerissue               I  Hello from service!
2026-04-04 17:32:02.267 11074-11128 ReactNativeJS           com.choreographerissue               I  Hello from service!
2026-04-04 17:32:02.526 11074-11074 Choreographer           com.choreographerissue               D  CoreRune.SYSPERF_ACTIVE_APP_BBA_ENABLE : stop animation in background states

MANDATORY Reproducer

https://github.com/gmanole01/react-native-choreographer-issue

Screenshots and Videos

No response

Activity

  1. pranjal2410719 commented on Apr 5, 2026

    @pranjal2410719

    Thanks for the detailed report and investigation — this is a great find.

    However, the issue is slightly deeper than just Samsung pausing Choreographer.

    🔍 Root cause

    React Native timers (via TimingModule) are currently driven by ReactChoreographer, which ultimately relies on Android’s Choreographer (vsync / UI frame callbacks).

    This creates a hidden assumption:

    JS timers are tied to the UI rendering loop

    That works fine in foreground apps, but breaks in background/headless scenarios.

    On devices like Samsung (OneUI), Choreographer is explicitly paused when the app goes to background (as seen in the logs), which means:

    • No frame callbacks
    • No timer ticks
    • JS timers stop executing

    So the real issue is:

    Timers in React Native depend on a UI-driven scheduling mechanism, even when running in Headless JS (which has no UI).


    ⚠️ Why this is problematic

    Headless JS is designed for background execution, but:

    • It still depends on a UI-bound scheduler
    • Behavior becomes OEM-dependent (Samsung exposes it clearly, but other vendors may also throttle frames)

    This makes timers unreliable in any background execution context.


    💡 Suggested fix (for React Native)

    React Native should decouple timer scheduling from Choreographer in non-UI contexts.

    A possible approach:

    • Use Choreographer only when the app is in foreground / UI is active
    • Fall back to a background-safe scheduler when running headless, such as:
      • Handler.postDelayed
      • ScheduledExecutorService

    In other words, a dual scheduling strategy:

    Context Scheduler
    Foreground (UI active) Choreographer
    Background / Headless JS Handler / Executor

    This would:

    • Make timers reliable in Headless JS
    • Remove OEM-specific behavior differences
    • Align with expectations of background services on Android

    🧪 Workarounds (for others hitting this)

    • Avoid relying on JS timers in Headless JS tasks
    • Use Android-native scheduling (WorkManager, AlarmManager, or a foreground service)
    • Or implement timing logic in a native module

    📌 Summary

    This is not just a Samsung-specific issue — it reveals a design limitation in how React Native schedules timers. Fixing it would improve reliability of background execution across all Android devices.

  2. gmanolek2 commented on Apr 6, 2026

    @gmanolek2

    Hello, @pranjal2410719

    Yes, I agree with you, the issue is a lot deeper. I have created a patch for React Native 0.84.1 in order to fix the issue.

    diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt b/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
    index 36116d46dda373633cd9217b12f4def08f2bd007..5996e7ddb87c842e5a3f61f1aa8c90dea61521bf 100644
    --- a/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
    +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
    @@ -38,9 +38,15 @@ import kotlin.math.sign
     public open class JavaTimerManager(
         private val reactApplicationContext: ReactApplicationContext,
         private val javaScriptTimerExecutor: JavaScriptTimerExecutor,
    -    private val reactChoreographer: ReactChoreographer,
    +    private val _reactChoreographer: ReactChoreographer,
         private val devSupportManager: DevSupportManager,
     ) : LifecycleEventListener, HeadlessJsTaskEventListener {
    +
    +  private val reactChoreographer: ReactTimerChoreographer
    +      get() {
    +        return ReactTimerChoreographer.getInstance()
    +      }
    +
       private class Timer(
           val timerId: Int,
           var targetTime: Long,
    @@ -70,9 +76,9 @@ public open class JavaTimerManager(
       }
     
       override fun onHostPause() {
    -    isPaused.set(true)
    -    clearFrameCallback()
    -    maybeIdleCallback()
    +    // isPaused.set(true)
    +    // clearFrameCallback()
    +    // maybeIdleCallback()
       }
     
       override fun onHostDestroy() {
    @@ -96,12 +102,12 @@ public open class JavaTimerManager(
       }
     
       override fun onHeadlessJsTaskFinish(taskId: Int) {
    -    val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactApplicationContext)
    -    if (!headlessJsTaskContext.hasActiveTasks()) {
    -      isRunningTasks.set(false)
    -      clearFrameCallback()
    -      maybeIdleCallback()
    -    }
    +   // val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactApplicationContext)
    +   // if (!headlessJsTaskContext.hasActiveTasks()) {
    +   //   isRunningTasks.set(false)
    +   //   clearFrameCallback()
    +   //   maybeIdleCallback()
    +   // }
       }
     
       public open fun onInstanceDestroy() {
    diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactChoreographer.kt b/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactChoreographer.kt
    index 85bcbcdd4378691cef62b53706f13300a2a3668b..c525e91f44569c8da98f011e59df488552f58955 100644
    --- a/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactChoreographer.kt
    +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactChoreographer.kt
    @@ -23,7 +23,7 @@ import java.util.ArrayDeque
      * one and the API's are safe to use from any thread.
      */
     public class ReactChoreographer private constructor(choreographerProvider: ChoreographerProvider) {
    -  public enum class CallbackType(internal val order: Int) {
    +  public enum class CallbackType(public val order: Int) {
         /** For use by perf markers that need to happen immediately after draw */
         PERF_MARKERS(0),
         /** For use by [com.facebook.react.bridge.UIManager] */
    diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactTimerChoreographer.java b/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactTimerChoreographer.java
    new file mode 100644
    index 0000000000000000000000000000000000000000..6d58a32989f1d1717565acfe261d1a06c8f81fc5
    --- /dev/null
    +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/ReactTimerChoreographer.java
    @@ -0,0 +1,189 @@
    +package com.facebook.react.modules.core;
    +
    +import android.os.Handler;
    +import android.os.Looper;
    +import android.view.Choreographer;
    +
    +import java.util.ArrayDeque;
    +import java.util.ArrayList;
    +
    +public class ReactTimerChoreographer {
    +    private final ArrayList<ArrayDeque<Choreographer.FrameCallback>> callbackQueues;
    +
    +    private final HandlerChoreographer choreographer;
    +    private int totalCallbacks = 0;
    +    private boolean hasPostedCallback = false;
    +
    +    private final Choreographer.FrameCallback frameCallback = new Choreographer.FrameCallback() {
    +        @Override
    +        public void doFrame(long frameTimeNanos) {
    +            synchronized(callbackQueues) {
    +                // Callbacks run once and are then automatically removed, the callback will
    +                // be posted again from postFrameCallback
    +                hasPostedCallback = false;
    +                for (int i = 0; i < callbackQueues.size(); i++) {
    +                    ArrayDeque<Choreographer.FrameCallback> callbackQueue = callbackQueues.get(i);
    +                    int initialLength = callbackQueue.size();
    +                    for (int j = 0; j < initialLength; j++) {
    +                        Choreographer.FrameCallback frameCallback = callbackQueue.pollFirst();
    +                        if (frameCallback != null) {
    +                            frameCallback.doFrame(frameTimeNanos);
    +                            totalCallbacks--;
    +                        }
    +                    }
    +                }
    +                maybeRemoveFrameCallback();
    +            }
    +        }
    +    };
    +
    +    private ReactTimerChoreographer() {
    +        this.choreographer = HandlerChoreographer.getInstance();
    +        int callbackQueuesLen = ReactChoreographer.CallbackType.getEntries().size();
    +        this.callbackQueues = new ArrayList<>();
    +        for (int i = 0; i < callbackQueuesLen; i++) {
    +            this.callbackQueues.add(new ArrayDeque<>());
    +        }
    +
    +    }
    +
    +    public void postFrameCallback(ReactChoreographer.CallbackType type, Choreographer.FrameCallback callback) {
    +        if (type != ReactChoreographer.CallbackType.TIMERS_EVENTS && type != ReactChoreographer.CallbackType.IDLE_EVENT) {
    +            // can only post timers and idle
    +            return;
    +        }
    +        synchronized(callbackQueues) {
    +            callbackQueues.get(type.getOrder()).addLast(callback);
    +            totalCallbacks++;
    +            postFrameCallbackOnChoreographer();
    +        }
    +    }
    +
    +    public void removeFrameCallback(ReactChoreographer.CallbackType type, Choreographer.FrameCallback frameCallback) {
    +        if (type != ReactChoreographer.CallbackType.TIMERS_EVENTS && type != ReactChoreographer.CallbackType.IDLE_EVENT) {
    +            // can only post timers and idle
    +            return;
    +        }
    +        synchronized(callbackQueues) {
    +            if (callbackQueues.get(type.getOrder()).removeFirstOccurrence(frameCallback)) {
    +                totalCallbacks--;
    +                maybeRemoveFrameCallback();
    +            }
    +        }
    +    }
    +
    +    private void postFrameCallbackOnChoreographer() {
    +        if (!hasPostedCallback) {
    +            choreographer.postFrameCallback(frameCallback);
    +            hasPostedCallback = true;
    +        }
    +    }
    +
    +    /**
    +     * This method reads and writes on mHasPostedCallback and it should be called from another method
    +     * that already has the lock on [callbackQueues].
    +     */
    +    private void maybeRemoveFrameCallback() {
    +        if (totalCallbacks == 0 && hasPostedCallback) {
    +            choreographer.removeFrameCallback(frameCallback);
    +            hasPostedCallback = false;
    +        }
    +    }
    +
    +    private static volatile ReactTimerChoreographer INSTANCE = null;
    +
    +    public static synchronized ReactTimerChoreographer getInstance() {
    +        if (INSTANCE == null) {
    +            synchronized (Object.class) {
    +                if (INSTANCE == null) {
    +                    INSTANCE = new ReactTimerChoreographer();
    +                }
    +            }
    +        }
    +        return INSTANCE;
    +    }
    +
    +    /**
    +     * Class that simulates Android's Choreographer, but using Handler class
    +     */
    +    public static class HandlerChoreographer {
    +        private final Object mLock = new Object();
    +        private final Handler mHandler;
    +        private final HandlerRunnable mHandlerRunnable;
    +
    +        private static final int ONE_SEC_IN_NANOS = 1000000;
    +        private static final int ONE_SEC_IN_MILLIS = 1000;
    +
    +        private int targetFps = 60;
    +        private int intervalMillis = ONE_SEC_IN_MILLIS / targetFps;
    +
    +        private final ArrayDeque<Choreographer.FrameCallback> mList = new ArrayDeque<>();
    +
    +        private HandlerChoreographer() {
    +            // create the handler on UI thread
    +            this.mHandler = new Handler(Looper.getMainLooper());
    +            this.mHandlerRunnable = new HandlerRunnable();
    +
    +            this.mHandler.postDelayed(this.mHandlerRunnable, intervalMillis);
    +        }
    +
    +        public void postFrameCallback(Choreographer.FrameCallback cb) {
    +            synchronized (mLock) {
    +                mList.addLast(cb);
    +            }
    +        }
    +
    +        public void removeFrameCallback(Choreographer.FrameCallback cb) {
    +            synchronized (mLock) {
    +                mList.removeFirstOccurrence(cb);
    +            }
    +        }
    +
    +        private static volatile HandlerChoreographer INSTANCE = null;
    +
    +        public static synchronized HandlerChoreographer getInstance() {
    +            if (INSTANCE == null) {
    +                synchronized (Object.class) {
    +                    if (INSTANCE == null) {
    +                        INSTANCE = new HandlerChoreographer();
    +                    }
    +                }
    +            }
    +            return INSTANCE;
    +        }
    +
    +        private class HandlerRunnable implements Runnable {
    +            @Override
    +            public void run() {
    +                long now = System.nanoTime();
    +
    +                synchronized (mLock) {
    +                    ArrayList<Choreographer.FrameCallback> callbacks = new ArrayList<>();
    +
    +                    // pull all callbacks from the list until it's empty
    +                    boolean runWhile = true;
    +                    while(runWhile) {
    +                        Choreographer.FrameCallback cb = mList.pollFirst();
    +                        if (cb != null) {
    +                            callbacks.add(cb);
    +                        } else {
    +                            runWhile = false;
    +                        }
    +                    }
    +
    +                    // run all of them in order
    +                    for(int i = 0; i < callbacks.size(); i++) {
    +                        Choreographer.FrameCallback cb = callbacks.get(i);
    +                        if (cb != null) {
    +                            cb.doFrame(now);
    +                        }
    +                    }
    +                }
    +
    +                // reschedule at the end
    +                mHandler.postDelayed(this, intervalMillis);
    +            }
    +        }
    +    }
    +
    +}
    
    

    This patch I created replaces ReactChoreographer used in the file JavaTimerManager.kt with ReactTimerChoreographer (class created by me) which uses Handler.postDelayed to drive the timers. This way, only the timers will use another ticking method, and the UI events and all the other things will use the Android's Choreographer. Certainly my implementation is not the best. I set the Handler to run every 16.66ms (to achieve 60 ticks per second). Also, I commented the lines that pause the timers when the app goes into background and there are no headless tasks running, because I want the timers to run all the time.

  3. pranjal2410719 commented on Apr 6, 2026

    @pranjal2410719

    Hey @gmanolek2 — really nice work digging into this and putting together a patch 👏
    This is a great proof-of-concept and makes it pretty clear that the root issue is tied to Choreographer being paused in the background.

    I also like that you kept the change scoped to timers and didn’t touch UI-related callbacks — that’s a good call.

    That said, I think there are a few things here that might make this tricky to use in production or upstream as-is:


    ⚠️ A couple of concerns

    1. Lifecycle handling

    By commenting out onHostPause and onHeadlessJsTaskFinish, timers will keep running even when:

    • the app is in the background
    • there are no active headless tasks

    That could lead to unnecessary CPU usage, battery drain, and possibly conflicts with Android’s background execution rules.


    2. The 60 FPS loop

    Using a Handler loop at ~16ms works, but it’s kind of simulating a frame loop rather than reacting to real timing signals.
    That might:

    • wake the CPU more than needed
    • drift over time
    • behave differently from actual vsync-driven scheduling

    3. Not context-aware

    Right now the scheduler is effectively replaced for timers in all cases.

    Ideally, it would switch depending on context:

    • Choreographer when the app is active (UI)
    • Handler / executor when running headless
    • paused when nothing is running

    💡 Possible direction

    Instead of replacing the scheduler globally, maybe a better approach would be:

    • Keep Choreographer for normal UI-driven timers
    • Use a background-safe scheduler (like Handler or ScheduledExecutorService) only for Headless JS
    • Switch between them based on lifecycle / headless task state

    That way you keep the benefits of both approaches without breaking lifecycle expectations.


    📌 Overall

    This patch does a really good job of proving the root cause and pointing in the right direction 👍
    The next step would probably be making the scheduling strategy lifecycle-aware rather than always-on.

    Would be great to keep iterating on this — feels like something worth fixing properly in core.

  4. react-native-bot commented on Oct 4, 2026

    @react-native-bot
    Collaborator

    This issue is stale because it has been open for 180 days with no activity. It will be closed in 7 days unless you comment on it or remove the "Stale" label.

  5. added
    StaleThere has been a lack of activity on this issue and it may be closed soon.
    on Oct 4, 2026
  6. react-native-bot commented on Oct 12, 2026

    @react-native-bot
    Collaborator

    This issue was closed because it has been stalled for 7 days with no activity.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Triage 🔍Platform: AndroidAndroid applications.StaleThere has been a lack of activity on this issue and it may be closed soon.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions