DEV Community

Cover image for React Native Local Notifications with Notifee — Foreground, Background, Killed + Progress & Images
Amit Kumar
Amit Kumar

Posted on

React Native Local Notifications with Notifee — Foreground, Background, Killed + Progress & Images

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:

  1. the notification to show
  2. a tap to open the right screen
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode
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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
  },
});
Enter fullscreen mode Exit fullscreen mode

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 },
  },
);
Enter fullscreen mode Exit fullscreen mode

Without alarmManager.allowWhileIdle, Doze will eat your “1 minute” demo for lunch.

Cancel:

await notifee.cancelNotification('demo-reminder-1min');
await notifee.cancelAllNotifications();
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
    }
  })();
}, []);
Enter fullscreen mode Exit fullscreen mode

Pattern that ships:

onBackgroundEvent  → stash route in AsyncStorage / memory
NavigationContainer onReady → read stash + getInitialNotification → navigate
onForegroundEvent  → navigate immediately
Enter fullscreen mode Exit fullscreen mode

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' },
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Indeterminate spinner while you don’t know size yet:

progress: { indeterminate: true }
Enter fullscreen mode Exit fullscreen mode

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' },
}
Enter fullscreen mode Exit fullscreen mode

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,
      },
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

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' },
}
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

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, …
Enter fullscreen mode Exit fullscreen mode

Common footguns

  1. No pressAction → tap does nothing useful on Android.
  2. Background handler only inside a screen → killed taps miss it.
  3. Navigate before NavigationContainer is ready → use a pending-intent stash.
  4. Wrong / missing channel → importance ignored.
  5. Exact alarm denied → “scheduled” means “whenever”.
  6. Progress without onlyAlertOnce → notification sound spam.
  7. Heavy work in onBackgroundEvent → ANRs / truncated execution. Persist + finish.
  8. iOS force-quit + BackgroundFetch → won’t run. Don’t promise it.
  9. Colored smallIcon → looks like a white blob; use a silhouette.
  10. Forgetting BackgroundFetch.finish(taskId) → OS throttles you into invisibility.

Takeaways

  1. Notifee owns local display + triggers — channels and exact alarms are half the product.
  2. Three states, two listenersonForegroundEvent + onBackgroundEvent, plus getInitialNotification for cold start.
  3. Same id = in-place progress updates — throttle and onlyAlertOnce.
  4. ImageslargeIcon / BIGPICTURE on Android; prefer local attachments on iOS.
  5. Background work ≠ reminder scheduling — Fetch/Headless for sync; AlarmManager for “at 3 PM”.
  6. 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)