In React Native on Android, how do AppRegistry.registerHeadlessTask and HeadlessJsTaskService work together to run JavaScript in the background?
answer
- Android-only, no UI mounted
- JS side: provider returning an async task
- native side: getTaskConfig(intent)
- resolved promise ends the task
- foreground start crashes by default
basics
~10 sHeadless JS is Android-only: AppRegistry.registerHeadlessTask registers an async function under a key, and a HeadlessJsTaskService subclass returns a HeadlessJsTaskConfig naming that key, so native triggers can run it without UI until its promise resolves.
solid answer
~40 sHeadless JS has two halves. In JavaScript, `AppRegistry.registerHeadlessTask('SyncTask', () => async data => {...})` registers a provider in the entry file; the task gets the data and must return a promise. On Android you subclass `HeadlessJsTaskService`, override `getTaskConfig(intent)` to return a `HeadlessJsTaskConfig` with the key, the data from the intent extras, a timeout and whether it may run in the foreground, declare the service in the manifest, and start it from a native trigger such as a `BroadcastReceiver`. The service takes a wake lock, spins up JavaScript without mounting UI, runs the task, and stops once every task it started has finished. By default starting a task while the app is in the foreground crashes, retries are off, and a timeout of `0` means none.
code
typescript · 12 lines// index.ts, next to AppRegistry.registerComponent
import { AppRegistry } from 'react-native';
import { syncPendingUploads } from './sync';
AppRegistry.registerHeadlessTask('SyncTask', () => async (data: { reason?: string }) => {
try {
await syncPendingUploads(data.reason);
} catch (error) {
// Resolve anyway: a rejected promise is not reported as finished.
console.warn('SyncTask failed', error);
}
});go deeper
Recall that Headless JS is Android-only, that the task is an async function registered on AppRegistry, and that native code has to start it.
Explain both halves: registerHeadlessTask in the entry file, a HeadlessJsTaskService subclass with getTaskConfig, the config's timeout and foreground flag, and how completion is signalled.
Show you know the failure modes: the foreground crash, a rejected promise holding the wake lock without a timeout, registration outside the entry, and Android's own limits on starting services.
Weigh a hand-written Headless JS trigger against Expo's task libraries or a native worker, considering who owns the native code and how the team tests background paths.
## What Headless JS is **Headless JS** is React Native's built-in way to run a JavaScript function on **Android** without any UI: no Activity, no mounted components. It is Android-only; iOS has no equivalent in React Native core. It has two halves, one in JavaScript and one in native code, and neither does anything on its own. ## The JavaScript half: `AppRegistry.registerHeadlessTask` You register a task under a string key, next to `AppRegistry.registerComponent` in the entry file: - The second argument is a **provider**: a function that returns the task function. - The task function receives the data native code passed and **must return a promise**. When the promise resolves, React Native tells the native side the task finished. - The task may make network requests, use timers and read storage, but it must not touch UI. `AppRegistry.registerCancellableHeadlessTask` is a variant that also takes a cancel provider, for tasks native code may abort. ## The native half: `HeadlessJsTaskService` On Android you subclass `HeadlessJsTaskService`, an Android `Service`, and override `getTaskConfig(intent)` to return a `HeadlessJsTaskConfig` (or `null` to ignore that start command). The config names the task key, the data (converted from the intent's extras with `Arguments.fromBundle`), and three optional settings: | Parameter | Default | Effect | |---|---|---| | `timeout` | `0` | Milliseconds before the task is forcibly finished; `0` means no timeout | | `isAllowedInForeground` | `false` | Whether the task may start while the app is in the foreground | | `retryPolicy` | no retries | A `HeadlessJsTaskRetryPolicy` such as `LinearCountingRetryPolicy` | The service is declared in `AndroidManifest.xml` and started from native code: a `BroadcastReceiver`, a scheduled job, or another component. When it starts a task it acquires a partial wake lock, creates a React instance if none exists, and calls into JavaScript. When every task it started has finished, it stops itself. If you start it from a `BroadcastReceiver`, the docs say to call `HeadlessJsTaskService.acquireWakeLockNow(context)` before `onReceive` returns. ## The lifecycle of one run 1. A native trigger starts your service with an intent. 2. `getTaskConfig` builds the config from the intent. 3. React Native spins up JavaScript if needed and calls the registered task with the data. 4. The promise resolves, React Native reports the task finished, and the service stops once none remain. 5. JavaScript goes back to a paused state unless other tasks are running or the app is in the foreground. While a headless task is active, React Native's Android timer manager keeps JavaScript timers running even though no Activity is resumed, which is why `setTimeout` works inside a task. ## The traps - **Foreground crash.** If the task starts while the app is resumed and `isAllowedInForeground` is `false`, the task context fails a check and throws an `IllegalStateException`. The docs put it bluntly: the app crashes. Either check that the app is not in the foreground before starting the service, or opt in with the fourth constructor argument. - **A rejected promise does not finish the task.** React Native logs the rejection; it only schedules a retry for the special retry error, and otherwise nothing reports completion. Without a `timeout`, the service and its wake lock stay alive. Catch errors inside the task and resolve, and set a timeout anyway. - **Registration outside the entry.** The provider must be registered in code that runs at startup, not inside a component, because a headless run mounts nothing. - **When Android lets you start a service.** Whether a background app may start a service, and which kind, is decided by Android's own background-execution rules, not by React Native. ## Starting the service Headless JS never starts itself. Something native has to decide that work is needed and start the service with an intent whose extras become the task's data. Typical triggers are a `BroadcastReceiver` reacting to a system event (the docs' example reacts to a connectivity change), a job scheduled by a native scheduler, or a native SDK callback that must hand work to JavaScript. The receiver should check that the app is not in the foreground before starting the service, as the docs' example does, unless the config allows foreground runs. Because the intent's extras are converted with `Arguments.fromBundle`, only values a `Bundle` can carry reach JavaScript; pass identifiers and read the rest from storage inside the task. ## Where it sits today Headless JS is the low-level primitive. Expo projects usually reach for `expo-task-manager` and `expo-background-task`, which schedule through the platform schedulers and use a headless task internally on Android to keep timers alive while a task executes. In a bare app, Headless JS is still how a custom native trigger hands work to JavaScript.
- Why can a React Native Headless JS task that rejects its promise keep the Android service alive?When the task's promise rejects, React Native logs the reason and only asks for a retry if the error is the special retry error. Otherwise nothing reports the task finished, so the service keeps its wake lock until the configured timeout, and with the default timeout of `0` there is none. Catch errors inside the task, resolve, and set a timeout.
- Why do JavaScript timers work inside a React Native Headless JS task when they are paused for a backgrounded app?React Native's Android timer manager normally stops driving timers when the host Activity pauses, but it keeps them running while any Headless JS task is active and pauses again when the last one finishes. That is also why `expo-task-manager` registers a headless task internally on Android: to keep timers alive while its background tasks execute.
Headless JS is a night-shift worker let in through the back door: the shop floor stays dark, the worker does one job from a note left by native code, and the door locks once they sign out, which happens when the job's promise resolves or their time limit runs out.
saying these in an interview costs you the question
- Headless JS works the same way on iOS through AppRegistry.
- Registering the task in JavaScript is enough; no native service is needed.
- A Headless JS task can safely start while the app is in the foreground by default.
- The task ends when the function returns, even if its promise is still pending.
- A rejected task promise is retried automatically three times.