skip to content

With expo-notifications, how do you run JavaScript when a data-only push arrives while the React Native app is killed?

level: middleimportance: should knowfreq 30%

answer

  1. a task, not a listener
  2. defineTask at module scope
  3. registerTaskAsync with the same name
  4. remote-notification background mode on iOS
  5. headless push: data plus content-available

basics

~10 s

Define a task with TaskManager.defineTask and register it with Notifications.registerTaskAsync, both at module scope in an early-loaded file, then send a headless background notification. iOS also needs the remote-notification background mode.

solid answer

~40 s

Listeners live inside a running app, so a killed app needs a **task**. Define it with `TaskManager.defineTask(name, executor)` from expo-task-manager and register it with `Notifications.registerTaskAsync(name)`, both at **module scope** in a file loaded early such as `index.ts`, because expo-task-manager loads the JS bundle in the background and runs the task without mounting your screens. The task receives `{ data, executionInfo, error }`. Only a **headless background notification** (data only, sent with `_contentAvailable: true` through Expo's push service) runs it when the app is terminated; a normal notification message is just displayed by the OS. On iOS enable the config plugin's `enableBackgroundRemoteNotifications`, which adds `remote-notification` to `UIBackgroundModes`, and optionally return a `BackgroundNotificationTaskResult`. Delivery is best-effort: Doze and Apple's background budget can drop it.

code

typescript · 16 lines
typescript
// index.ts, evaluated before the app is registered
import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';
import 'expo-router/entry';

const CHAT_SYNC_TASK = 'chat-sync-on-push';

TaskManager.defineTask<Notifications.NotificationTaskPayload>(CHAT_SYNC_TASK, async ({ data }) => {
  if ('actionIdentifier' in data) {
    return Notifications.BackgroundNotificationTaskResult.NoData;
  }
  // fetch and store the changed conversation here
  return Notifications.BackgroundNotificationTaskResult.NewData;
});

Notifications.registerTaskAsync(CHAT_SYNC_TASK);

go deeper

for a junior

Remember that a killed app needs a task, defined with TaskManager.defineTask and registered with Notifications.registerTaskAsync, not a listener.

for a middle

Explain module-scope registration, which push type runs the task in each state, the iOS remote-notification background mode and the payload the executor receives.

for a senior

Design around best-effort delivery: use the task for cache warming, keep user-facing alerts as notification messages, distinguish action taps from data pushes, and plan for invisible logs.

for a principal

Decide which product features may depend on background execution at all, given OS budgets and Doze, and which must be driven by visible notifications or the next app open.

## Why a listener is not enough `addNotificationReceivedListener` and `setNotificationHandler` are JavaScript callbacks registered by a running app. When the app is killed there is no running JavaScript to call them, so expo-notifications provides a different mechanism: a **background notification task** built on **expo-task-manager**. When a qualifying push arrives, expo-task-manager starts the JavaScript bundle in the background and executes the registered task, without the app coming to the foreground. ## The setup 1. **Define the task** with `TaskManager.defineTask(TASK_NAME, executor)`. 2. **Register it** with `Notifications.registerTaskAsync(TASK_NAME)`, passing the same name. 3. Do both at **module scope** in a file that loads early, such as the project's `index.ts`, not inside a component or effect. expo-task-manager runs the bundle in the background, and only code that executes as the module loads is guaranteed to have run. 4. On iOS, enable the **`remote-notification`** background mode. With Continuous Native Generation, set `enableBackgroundRemoteNotifications: true` in the expo-notifications config plugin; prebuild then adds it to `UIBackgroundModes`. It is not added by default. The executor receives an object with: - `data` — the payload delivered by FCM or APNs; - `executionInfo` — metadata including the `taskName`; - `error` — expected to be undefined for push tasks. On iOS the executor may return a `Notifications.BackgroundNotificationTaskResult` value (`NewData`, `NoData`, `Failed`), which reports the outcome of the background fetch. ## Which pushes run it | Push | App foreground | App background | App terminated | |---|---|---|---| | Notification message | Listener and task run | OS shows it | OS shows it | | Headless background notification | Listener and task run | Task runs | Task runs | So the **terminated** case needs a **headless background notification**: data only, no title or body. With Expo's push service that means sending `data` with `_contentAvailable: true`. One documented exception: on Android, if the data contains `title` or `message`, expo-notifications presents the headless notification automatically; iOS does not. Also on Android, the same task runs when the user taps a notification **action button** while the app is backgrounded or terminated, so the executor should check whether `data` is a notification response (it contains `actionIdentifier`). ## Limits to design for - **Delivery is not guaranteed.** The OS may withhold background notifications, for example under Android's Doze mode, or when an app receives many; Apple recommends no more than two or three per hour. - **Keep the work short.** Fetch the new messages, write them to storage, perhaps present a local notification, and return. - **No UI.** The task runs without screens; do not update React state from it. - **Logs may be invisible.** Expo warns that `console.log` output from background tasks may not show, depending on platform and state. ## Reading the payload The `data` the task receives is the raw remote payload. expo-notifications types it as `NotificationTaskPayload`: either a notification response, or an object with `notification` (which is `null` for headless background notifications), `data`, and on iOS the raw `aps` dictionary. The custom JSON may arrive as a string in `data.dataString`, so parse it defensively and treat every field as optional; a task that throws on an unexpected shape fails silently in the background. ## Applying it to a chat app A reasonable use is **cache warming**: when a headless push says a conversation changed, the task fetches its latest messages and stores them, so opening the notification later shows the conversation instantly. The user-facing "new message" alert should still be a normal notification message, because the task might never run. ## Common mistakes - Registering the task inside `useEffect`, so it never exists when the app is killed. - Sending a notification message and expecting the task to run while terminated. - Forgetting the iOS background mode, which silently disables headless delivery there. - Relying on the task for anything the user must see.

  • Why must defineTask run at module scope rather than inside the root component?
    When a headless push arrives for a killed app, expo-task-manager loads the JavaScript bundle and runs the task without the app coming to the foreground. Code at module scope in an early-loaded file runs as that bundle loads, so the task definition exists; code inside a component or effect is not guaranteed to run first, so the task may not be defined when it is needed.
  • How does a background notification task tell a data push apart from an action-button tap?
    The task payload is either a notification response or a remote-payload object. A response contains `actionIdentifier`, so the executor checks `'actionIdentifier' in data`. On Android the task runs for action taps while the app is backgrounded or terminated, so the check keeps a tap from being treated as a sync push.

saying these in an interview costs you the question

  • addNotificationReceivedListener runs even when the app is killed
  • Registering the task inside the root component's useEffect is enough
  • A normal notification message also runs the task while the app is terminated
  • The iOS background mode is enabled by expo-notifications without any configuration
  • Background notification tasks are guaranteed to run for every push