With expo-notifications, how do you run JavaScript when a data-only push arrives while the React Native app is killed?
answer
- a task, not a listener
- defineTask at module scope
- registerTaskAsync with the same name
- remote-notification background mode on iOS
- headless push: data plus content-available
basics
~10 sDefine 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 sListeners 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// 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
Remember that a killed app needs a task, defined with TaskManager.defineTask and registered with Notifications.registerTaskAsync, not a listener.
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.
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.
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