skip to content

Background Execution

Running JS when the app is not in front: Headless JS on Android, expo-task-manager and expo-background-task, and iOS BGTaskScheduler windows. Interviewers ask why periodic work is best effort.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

4

With expo-task-manager in a React Native app, why must TaskManager.defineTask be called at module top level rather than inside a component?

level: juniorimportance: should knowfreq 22%

answer

  1. background launch mounts no views
  2. only module scope runs
  3. executor lives in an in-memory map
  4. undefined name gets unregistered
  5. define first, register second

basics

~20 s

When the OS wakes an Expo app for a task, React Native evaluates the JavaScript bundle but mounts no views, so a defineTask inside a component never runs. expo-task-manager then finds no task, logs a warning and unregisters it.

solid answer

~40 s

`TaskManager.defineTask(name, executor)` stores the executor in an in-memory map in the JavaScript bundle, while registration, such as `BackgroundTask.registerTaskAsync(name)`, tells the native side to schedule work under that name. The work usually arrives with the app in the background or after it was killed: React Native starts, evaluates the bundle from its entry, and mounts no views. Only module-scope code runs, so a `defineTask` inside a component body or `useEffect` never executes. When the event arrives for an undefined name, `expo-task-manager` logs a warning, reports it finished and unregisters the task, so it silently stops running. Define tasks at top level in a module the entry imports, and register them from the UI when you choose.

code

typescript · 19 lines
typescript
// tasks/sync.ts, imported from the app entry so it runs on every launch
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import { syncPendingChanges } from '../sync';

export const SYNC_TASK = 'sync-pending-changes';

TaskManager.defineTask(SYNC_TASK, async () => {
  try {
    await syncPendingChanges();
    return BackgroundTask.BackgroundTaskResult.Success;
  } catch {
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
});

export function enableBackgroundSync() {
  return BackgroundTask.registerTaskAsync(SYNC_TASK, { minimumInterval: 60 });
}

go deeper

for a junior

Remember the rule and the reason: define tasks at module top level, because a background launch evaluates the bundle but renders nothing.

for a middle

Explain the split between defineTask, which lives in the JavaScript bundle, and registration, which the native side persists, and what happens to an event for an undefined name.

for a senior

Show how you keep task modules imported from the entry, test a real background launch on a device, and watch for the auto-unregister warning after a rename.

for a principal

Treat background tasks as a separate entry point of the app with its own dependencies and tests, since it runs without the UI tree the rest of the team relies on.

## What `defineTask` actually does `expo-task-manager` is the Expo module that other Expo libraries use to run JavaScript when the operating system wakes the app for background work: `expo-background-task`, background location in `expo-location`, and background notification handling. It splits the job in two: - **`TaskManager.defineTask(name, executor)`** stores your executor function in an in-memory map inside the JavaScript bundle, keyed by the task name. - **Registration** (for example `BackgroundTask.registerTaskAsync(name)` or `Location.startLocationUpdatesAsync(name, options)`) tells the native side to schedule or subscribe work under that name. Registered tasks are saved in persistent storage on the native side and restored when the app initialises. When the OS later runs the work, native code sends an event with the task name, and the JavaScript side looks the name up in that map and calls the executor with `{ data, error, executionInfo }`. ## Why the call must be at module top level The work usually arrives **while the app is not on screen**, and often after the process was killed. The OS then launches the app in the background: React Native starts, the JavaScript bundle is evaluated from its entry file, and **no views are mounted**. The `defineTask` documentation says exactly this: the app spins up JavaScript, runs the task and shuts down, so the call cannot live in a React lifecycle method. In that launch, only module-scope code runs: | Where `defineTask` is called | Foreground launch | Background launch with no UI | |---|---|---| | Top level of a module imported by the entry file | Runs | Runs | | Inside a component body or `useEffect` | Runs when that component mounts | Never runs, because nothing renders | | Inside a screen that is only mounted after navigation | Runs only after the user reaches the screen | Never runs | So a task defined inside a component appears to work in development, where the app is open, and fails in production exactly when it matters. ## What happens when the name is not defined `expo-task-manager` handles a task event for an undefined name in a way that makes the bug worse than a crash: 1. It logs a warning that execution was requested but the task "looks like it is not defined", listing the tasks that are. 2. It reports the event as finished to the native side. 3. It **unregisters the task automatically**, on the assumption that it was renamed or removed. The next time the app is opened, nothing is scheduled any more, and the only trace is a warning in a background run nobody watched. Registration has a guard in the other direction: `BackgroundTask.registerTaskAsync` throws if no task with that name has been defined yet, so defining first and registering second is required anyway. ## The correct shape 1. Put `defineTask` in its own module at top level, for example `tasks/sync.ts`. 2. Import that module from the app's entry, or from the root layout that the entry always evaluates, so it runs on every launch. 3. Register (or start location updates) from wherever it makes sense in the UI, for example after the user opts in; registration may live in a component. 4. Keep the executor self-contained: it must not rely on React state, context or hooks, because none exist in a background launch. Read what it needs from storage. ## How the bug usually shows up A team adds a nightly sync. The developer defines the task inside the settings screen, right next to the toggle that registers it, and tests with the app open: the task is defined, the registration succeeds, and a manual trigger runs it. After release, users who never open the settings screen in a session get no syncs at all. The OS did wake the app, but the background launch evaluated only the entry and its imports, the settings screen never rendered, the event found no executor under that name, and `expo-task-manager` unregistered the task. The fix is one import moved to the entry, but finding it takes a device, a killed app and patience, which is why interviewers like the question: it tests whether you know how a background launch differs from a normal one. ## Related details worth knowing - The executor receives an `error` field; check it before using `data`. - If the executor throws, `expo-task-manager` logs the error and still reports the task as finished, so the OS is not left waiting. - In Expo Go, `TaskManager` is not available on Android and does not run background work on iOS; testing needs a development build. - `TaskManager.isTaskDefined(name)` and `TaskManager.isTaskRegisteredAsync(name)` let you assert the two halves separately in a debug screen.

  • What does BackgroundTask.registerTaskAsync do if TaskManager.defineTask has not been called for that name yet?
    It throws an error saying the task is not defined and that you must define it with `TaskManager.defineTask` before registering. That is why the module holding `defineTask` must be imported before any code that registers the task runs.
  • Why can a TaskManager executor not read values from React context or a hook?
    In a background launch no component tree exists, so there is no provider, no context value and no hook to call. The executor is a plain async function run by the task system; it must read what it needs from storage or module-level objects and write its results back to storage.

saying these in an interview costs you the question

  • Defining the task in useEffect is fine because the app root always mounts.
  • If a task is not defined, expo-task-manager queues the event until it is.
  • Registration alone is enough; defineTask is only needed for foreground runs.
  • The executor can use React state because it runs inside the app component.
  • Background tasks can be fully tested in Expo Go without a development build.
open as a page

In React Native on Android, how do AppRegistry.registerHeadlessTask and HeadlessJsTaskService work together to run JavaScript in the background?

level: middleimportance: should knowfreq 32%

basics

~10 s

Headless 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.

open as a page

In an Expo app, why does a task registered with BackgroundTask.registerTaskAsync and minimumInterval: 15 not run every 15 minutes?

level: middleimportance: should knowfreq 38%

basics

~20 s

expo-background-task hands the work to WorkManager on Android and BGTaskScheduler on iOS, which run it when battery, network and usage patterns suit them; minimumInterval is only a floor in minutes, so runs are best effort.

open as a page

A React Native running app must upload the GPS track while backgrounded; why is a periodic background task not enough, and how would you design the sync?

level: seniorimportance: should knowfreq 26%

basics

~20 s

A periodic expo-background-task runs when the OS chooses, possibly hours later. Background location keeps the app running, so sync inside the TaskManager location task: persist each batch, then upload pending points, with a periodic task as backstop.

open as a page