React Native Local Notifications with Notifee — Foreground, Background, Killed + Progress & Images
You schedule a reminder.
You start a download.
You kill the app.
Users still expect:
- the notification to show
- a tap to open the right screen
- a progress bar / image to look native
@notifee/react-native is the library that makes local notifications feel like first-class Android / iOS work — not a JS toy.
This is the practical write-up for RN 0.86: channels, permissions, all three app states, progress indicators, images, and how this pairs with background tasks.
TL;DR
| Job | Notifee API | Survives kill? |
|---|---|---|
| Show now | displayNotification |
N/A (app must be alive to call it) |
| Fire at a time |
createTriggerNotification + AlarmManager |
✅ Android |
| Handle press while app open | onForegroundEvent |
— |
| Handle press while background / killed |
onBackgroundEvent in index.js
|
✅ |
| Progress bar (download / sync) | android.progress |
Update same id
|
| Big image |
android.largeIcon / bigPicture / ios.attachments
|
✅ |
| Periodic sync → then notify | BackgroundFetch + displayNotification
|
✅ Android headless |
iOS reality check: force-quit kills background fetch. Trigger notifications and tap-to-open still work; silent “do work then notify” after kill needs push.
Mental model: three app states
FOREGROUND — app visible, JS running
→ notifee.onForegroundEvent(...)
→ you decide: show in-app banner, or suppress OS tray noise
BACKGROUND — app in Recents, process may still be warm
→ OS shows the notification
→ tap → onBackgroundEvent (or cold start + getInitialNotification)
KILLED — process gone
→ trigger / AlarmManager can still display
→ tap relaunches JS → onBackgroundEvent / getInitialNotification
→ Headless JS can displayNotification after sync (Android)
Rule of thumb:
- Display is OS-owned once scheduled or posted.
- Press handling is your job in both foreground and background listeners.
-
Never put navigation-only logic only in a screen
useEffect— killed taps won’t see it.
Install
yarn add @notifee/react-native
# if you also sync in the background:
yarn add react-native-background-fetch @react-native-async-storage/async-storage
cd ios && pod install
Autolinking covers native wiring on modern RN. You still own permissions and channels.
Android permissions that actually matter
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
| Permission | Why you care |
|---|---|
POST_NOTIFICATIONS |
Android 13+ — without it, silence |
SCHEDULE_EXACT_ALARM |
Reminders that fire when the app is dead |
RECEIVE_BOOT_COMPLETED |
Reschedule / BackgroundFetch after reboot |
WAKE_LOCK |
Headless sync work |
Channels first (Android)
No channel → your “HIGH importance” notification is a shrug.
import notifee, { AndroidImportance } from '@notifee/react-native';
import { Platform } from 'react-native';
const CHANNELS = {
reminders: 'reminders',
downloads: 'downloads',
sync: 'sync',
};
async function ensureChannels() {
if (Platform.OS !== 'android') return;
await notifee.createChannel({
id: CHANNELS.reminders,
name: 'Reminders',
importance: AndroidImportance.HIGH,
vibration: true,
});
await notifee.createChannel({
id: CHANNELS.downloads,
name: 'Downloads',
importance: AndroidImportance.LOW, // progress updates shouldn't scream
vibration: false,
});
await notifee.createChannel({
id: CHANNELS.sync,
name: 'Background sync',
importance: AndroidImportance.DEFAULT,
});
}
Split channels by user expectation, not by screen. Progress ≠ alarm.
Permissions (iOS + Android 13+)
import notifee, {
AuthorizationStatus,
AndroidNotificationSetting,
} from '@notifee/react-native';
export async function requestNotificationPermission() {
const settings = await notifee.requestPermission();
return (
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL
);
}
export async function ensureExactAlarms() {
if (Platform.OS !== 'android') return true;
const settings = await notifee.getNotificationSettings();
if (settings.android.alarm === AndroidNotificationSetting.ENABLED) {
return true;
}
await notifee.openAlarmPermissionSettings();
return false;
}
Exact alarms are a product permission, same tier as camera. If disabled, triggers often wait until the user opens the app — looks like a bug.
1) Instant local notification
await ensureChannels();
await notifee.displayNotification({
id: 'welcome-1',
title: 'Welcome',
body: 'Local notification from Notifee',
data: { screen: 'Home', itemId: '42' },
android: {
channelId: CHANNELS.reminders,
pressAction: { id: 'default' }, // required for tap to open app
smallIcon: 'ic_launcher', // or your drawable name
},
ios: {
sound: 'default',
},
});
pressAction: { id: 'default' } is the difference between “pretty banner” and “tappable deep link”.
2) Scheduled reminder (survives kill on Android)
import { TriggerType, AndroidImportance } from '@notifee/react-native';
await notifee.createTriggerNotification(
{
id: 'demo-reminder-1min',
title: 'Reminder',
body: 'Still fired after you killed the app.',
data: { screen: 'Details', itemId: 'reminder-1' },
android: {
channelId: CHANNELS.reminders,
pressAction: { id: 'default' },
importance: AndroidImportance.HIGH,
},
ios: { sound: 'default' },
},
{
type: TriggerType.TIMESTAMP,
timestamp: Date.now() + 60_000,
alarmManager: { allowWhileIdle: true },
},
);
Without alarmManager.allowWhileIdle, Doze will eat your “1 minute” demo for lunch.
Cancel:
await notifee.cancelNotification('demo-reminder-1min');
await notifee.cancelAllNotifications();
3) Handle foreground / background / killed
This is the part most tutorials under-explain.
Foreground (app open)
Register once near the root (e.g. App.js):
import notifee, { EventType } from '@notifee/react-native';
import { useEffect } from 'react';
import { NavigationService } from './src/services';
useEffect(() => {
const unsubscribe = notifee.onForegroundEvent(({ type, detail }) => {
switch (type) {
case EventType.DELIVERED:
// optional: in-app toast instead of tray spam
break;
case EventType.PRESS:
handleNotificationPress(detail.notification);
break;
case EventType.ACTION_PRESS:
// custom buttons (Mark done, Reply, …)
break;
case EventType.DISMISSED:
break;
}
});
return unsubscribe;
}, []);
function handleNotificationPress(notification) {
const data = notification?.data ?? {};
if (data.screen) {
NavigationService.navigate(data.screen, { id: data.itemId });
}
}
Background + killed (must live outside React)
Put this in index.js before AppRegistry — same rule as Headless JS:
import 'react-native-gesture-handler';
import { AppRegistry } from 'react-native';
import notifee, { EventType } from '@notifee/react-native';
import BackgroundFetch from 'react-native-background-fetch';
import App from './App';
import { name as appName } from './app.json';
import { backgroundHeadlessTask } from './src/services/backgroundHeadlessTask';
notifee.onBackgroundEvent(async ({ type, detail }) => {
const { notification, pressAction } = detail;
if (type === EventType.PRESS) {
// Persist intent — NavigationContainer may not be ready yet
// e.g. AsyncStorage.setItem('@pending_notification', JSON.stringify(notification?.data))
console.log('[Notifee] background press', notification?.id, pressAction?.id);
}
if (type === EventType.ACTION_PRESS && pressAction?.id === 'mark-done') {
// lightweight work only — no heavy UI
}
});
BackgroundFetch.registerHeadlessTask(backgroundHeadlessTask);
AppRegistry.registerComponent(appName, () => App);
Cold start from a tap (killed → launch)
When the process was dead, also check what opened the app:
// App.js or root navigator — after NavigationContainer is ready
useEffect(() => {
(async () => {
const initial = await notifee.getInitialNotification();
if (initial?.notification) {
handleNotificationPress(initial.notification);
}
})();
}, []);
Pattern that ships:
onBackgroundEvent → stash route in AsyncStorage / memory
NavigationContainer onReady → read stash + getInitialNotification → navigate
onForegroundEvent → navigate immediately
Don’t navigate from onBackgroundEvent into a tree that doesn’t exist yet.
4) Progress indicator (downloads / long work)
Reuse the same notification id. Android replaces the tray entry in place.
const DOWNLOAD_ID = 'download-video-1';
async function startDownloadNotification() {
await ensureChannels();
await notifee.displayNotification({
id: DOWNLOAD_ID,
title: 'Downloading…',
body: '0%',
android: {
channelId: CHANNELS.downloads,
onlyAlertOnce: true, // don't buzz on every %
progress: {
max: 100,
current: 0,
indeterminate: false,
},
pressAction: { id: 'default' },
ongoing: true, // user can't swipe away mid-download (optional)
},
});
}
async function updateDownloadProgress(percent) {
await notifee.displayNotification({
id: DOWNLOAD_ID,
title: 'Downloading…',
body: `${percent}%`,
android: {
channelId: CHANNELS.downloads,
onlyAlertOnce: true,
progress: {
max: 100,
current: percent,
indeterminate: false,
},
pressAction: { id: 'default' },
ongoing: true,
},
});
}
async function finishDownloadNotification() {
await notifee.displayNotification({
id: DOWNLOAD_ID,
title: 'Download complete',
body: 'Tap to open',
android: {
channelId: CHANNELS.downloads,
progress: undefined, // clear progress bar
ongoing: false,
pressAction: { id: 'default' },
},
});
}
Indeterminate spinner while you don’t know size yet:
progress: { indeterminate: true }
Throttle updates (e.g. every 250–500 ms or every 1%). Flooding the notification manager from a tight loop is how you invent jank.
5) Images on notifications
Large icon (small circle / square beside text)
android: {
channelId: CHANNELS.reminders,
largeIcon: 'https://picsum.photos/256', // remote URL
// or require / local file URI depending on your asset pipeline
pressAction: { id: 'default' },
}
Big picture style (expanded image)
import { AndroidStyle } from '@notifee/react-native';
await notifee.displayNotification({
id: 'promo-1',
title: 'New drop',
body: 'Expand to see the cover',
android: {
channelId: CHANNELS.reminders,
pressAction: { id: 'default' },
largeIcon: 'https://picsum.photos/256',
style: {
type: AndroidStyle.BIGPICTURE,
picture: 'https://picsum.photos/800/400',
},
},
ios: {
attachments: [
{
// local file path or bundled asset URL iOS can read
url: 'https://picsum.photos/800/400',
thumbnailHidden: false,
},
],
},
});
Notes that save hours:
- Android can often fetch HTTPS images for
largeIcon/bigPicture. - iOS attachments are pickier — prefer local files you already downloaded.
- Always provide a smallIcon drawable on Android (white silhouette on transparent). Colored PNGs look broken in the status bar.
- Remote images can fail offline — fall back to text-only notification.
Local file example (after you cached an image)
android: {
channelId: CHANNELS.reminders,
largeIcon: filePath, // e.g. from RNFS / CacheDir
style: {
type: AndroidStyle.BIGPICTURE,
picture: filePath,
},
pressAction: { id: 'default' },
}
6) Action buttons
await notifee.displayNotification({
id: 'task-1',
title: 'Stand up',
body: 'Meeting in 5 minutes',
android: {
channelId: CHANNELS.reminders,
pressAction: { id: 'default' },
actions: [
{ title: 'Snooze', pressAction: { id: 'snooze' } },
{ title: 'Done', pressAction: { id: 'mark-done' } },
],
},
ios: {
categoryId: 'reminder', // register categories via notifee.setNotificationCategories
},
});
Handle EventType.ACTION_PRESS in both foreground and background listeners. Background handler should stay light (persist flag, cancel notification, schedule snooze) — not mount React.
7) Pair with background tasks
Local notifications + BackgroundFetch is the combo:
| Flow | Who wakes you | What you show |
|---|---|---|
| Reminder at 3:00 PM | Notifee trigger / AlarmManager | Pre-built notification |
| Sync every ~15 min | BackgroundFetch / Headless JS | Optional displayNotification after work |
| Download while app open | Your JS loop | Progress notification updates |
Keep sync work pure (no navigation imports) so Headless can call it:
// syncJob.js — safe for Headless JS
export async function runSyncJob() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos/1');
const payload = await res.json();
await AsyncStorage.setItem(KEY, JSON.stringify({ at: Date.now(), payload }));
return payload;
}
Headless → notify (Android, after kill):
// backgroundHeadlessTask.js
import notifee, { AndroidImportance } from '@notifee/react-native';
import BackgroundFetch from 'react-native-background-fetch';
import { runSyncJob } from './syncJob';
export async function backgroundHeadlessTask({ taskId, timeout }) {
if (timeout) {
BackgroundFetch.finish(taskId);
return;
}
try {
const payload = await runSyncJob();
await notifee.displayNotification({
id: `sync-${Date.now()}`,
title: 'Sync complete',
body: payload?.title ?? 'Background sync finished',
android: {
channelId: 'sync',
pressAction: { id: 'default' },
importance: AndroidImportance.DEFAULT,
},
});
} finally {
BackgroundFetch.finish(taskId); // never skip
}
}
Register headless before AppRegistry (see earlier index.js).
Configure BackgroundFetch with stopOnTerminate: false + enableHeadless: true if you want this after swipe-away. Details: same pattern as our background sync guide.
Wire it on launch
// App.js
useEffect(() => {
let cancelled = false;
(async () => {
await ensureChannels();
await requestNotificationPermission();
// BackgroundSyncService.init() if you use fetch
})();
const unsub = notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS) {
handleNotificationPress(detail.notification);
}
});
return () => {
cancelled = true;
unsub();
};
}, []);
index.js owns onBackgroundEvent + headless registration.
App.js owns channels, permission, foreground events, cold-start getInitialNotification.
Settings lab (demo in 90 seconds)
Ship a Settings screen that can:
- request notification permission
- open exact-alarm settings
- Notify now (plain)
- Notify with image
- Start fake download (0 → 100% progress)
- Schedule reminder in 1 min
- Cancel all
- show last background sync time
Verification matrix:
| Test | Steps | Pass |
|---|---|---|
| Foreground press | App open → notify now → tap | Navigates / logs PRESS |
| Background press | Notify → Home button → tap tray | Opens app + handles data |
| Killed reminder | Schedule 1 min → swipe away → wait | Notification appears |
| Killed tap | Tap that notification | Lands on intended screen |
| Progress | Start download demo | Bar updates, then completes |
| Image | Notify with big picture | Expanded image shows |
| Headless notify | Kill app → wait for fetch (or force) | Sync notification appears (Android) |
File map
index.js # onBackgroundEvent + registerHeadlessTask
App.js # channels, permission, onForegroundEvent
src/services/
NotificationService.js # schedule / cancel / permission helpers
BackgroundSyncService.js # BackgroundFetch configure
backgroundHeadlessTask.js # killed-state sync (+ optional notify)
syncJob.js # pure work
NavigationService.js # navigate from notification data
AndroidManifest.xml # POST_NOTIFICATIONS, SCHEDULE_EXACT_ALARM, …
Common footguns
-
No
pressAction→ tap does nothing useful on Android. - Background handler only inside a screen → killed taps miss it.
-
Navigate before
NavigationContaineris ready → use a pending-intent stash. - Wrong / missing channel → importance ignored.
- Exact alarm denied → “scheduled” means “whenever”.
-
Progress without
onlyAlertOnce→ notification sound spam. -
Heavy work in
onBackgroundEvent→ ANRs / truncated execution. Persist + finish. - iOS force-quit + BackgroundFetch → won’t run. Don’t promise it.
-
Colored
smallIcon→ looks like a white blob; use a silhouette. -
Forgetting
BackgroundFetch.finish(taskId)→ OS throttles you into invisibility.
Takeaways
- Notifee owns local display + triggers — channels and exact alarms are half the product.
-
Three states, two listeners —
onForegroundEvent+onBackgroundEvent, plusgetInitialNotificationfor cold start. -
Same
id= in-place progress updates — throttle andonlyAlertOnce. -
Images —
largeIcon/BIGPICTUREon Android; prefer local attachments on iOS. - Background work ≠ reminder scheduling — Fetch/Headless for sync; AlarmManager for “at 3 PM”.
-
Headless code stays boring — fetch, storage,
displayNotification,finish. No React tree.
What’s next (prod)
- Deep link map:
data.screen→ typed routes - Notification categories / reply actions on iOS
- Group notifications (
android.groupId) for chat-style apps - FCM / APNs for server push; keep Notifee for local display + rich UI
- Don’t update progress every byte — batch it
If this helped, drop a ❤️ and tell me what you’re notifying on (downloads, habits, chat, sync). Happy to do a follow-up on Notifee + FCM (remote message → local rich notification) or OEM battery hell.
Top comments (0)