Repository navigation
[Android] Timers do not run when using HeadlessJsTaskService #56324
Description
Activity
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 byReactChoreographer, which ultimately relies on Android’sChoreographer(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),
Choreographeris 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 problematicHeadless 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
Choreographerin non-UI contexts.A possible approach:
- Use
Choreographeronly when the app is in foreground / UI is active - Fall back to a background-safe scheduler when running headless, such as:
Handler.postDelayedScheduledExecutorService
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.
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.ktwith ReactTimerChoreographer (class created by me) which usesHandler.postDelayedto 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'sChoreographer. 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.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 toChoreographerbeing 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 concerns1. Lifecycle handling
By commenting out
onHostPauseandonHeadlessJsTaskFinish, 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
Handlerloop 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:
Choreographerwhen 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
Choreographerfor normal UI-driven timers - Use a background-safe scheduler (like
HandlerorScheduledExecutorService) 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.
- added a commit that references this issue
on Apr 10, 2026 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.
- addedStaleThere has been a lack of activity on this issue and it may be closed soon.There has been a lack of activity on this issue and it may be closed soon.
on Oct 4, 2026 This issue was closed because it has been stalled for 7 days with no activity.
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
React Native Version
0.84.1
Affected Platforms
Runtime - Android
Output of
npx @react-native-community/cli infoStacktrace or Logs
MANDATORY Reproducer
https://github.com/gmanole01/react-native-choreographer-issue
Screenshots and Videos
No response