Why Notifications Are the Hardest "Easy" Thing in Mobile Dev
Notifications seem simple. A title, a body, maybe an icon — how hard can it be?
Then you actually start building. You discover that iOS and Android handle permissions differently. You learn that Android 13 requires explicit opt-in. You find out that Android drops notifications silently if you forget to create a channel. And then you realize Expo Go no longer supports remote push notifications as of SDK 53. What seemed like a weekend feature suddenly turns into a week-long odyssey through platform quirks and silent failures. Fun times.
I went through that odyssey so you don't have to. This guide covers everything: setting up remote push notifications with Expo's push service, scheduling local notifications without any server, configuring Android notification channels, building interactive notifications with action buttons, and — critically — handling errors in production so your notifications actually reach your users. Every code example here is tested against Expo SDK 55 and React Native 0.79+.
Setting Up Your Project for Notifications
Installing Dependencies
You need three packages: expo-notifications for the core notification APIs, expo-device to check whether you're running on a physical device (since notifications don't work on emulators), and expo-constants to access your project ID for generating Expo push tokens.
npx expo install expo-notifications expo-device expo-constants
Development Build Required (SDK 53+)
This is the single biggest gotcha for developers in 2026: push notifications no longer work in Expo Go on Android as of SDK 53. They were deprecated in SDK 52 with a warning, and the support was fully removed in SDK 53. You must use a development build.
If you don't already have a dev build set up, here's how to create one:
npx expo install expo-dev-client
npx expo prebuild
npx expo run:ios
# or
npx expo run:android
Local notifications still work in Expo Go, so if you're just prototyping scheduled reminders, you can start there. But for remote push notifications, a dev build is non-negotiable.
Configuring app.json
Add the notification configuration to your app.json (or app.config.js, if that's your thing):
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#ffffff",
"defaultChannel": "default",
"sounds": ["./assets/sounds/notification.wav"]
}
]
],
"android": {
"useNextNotificationsApi": true
},
"ios": {
"supportsTablet": true
}
}
}
The icon property sets the Android notification icon. Follow Google's design guidelines here: it must be all white with a transparent background, or it'll render as a solid square on some devices. I learned this one the hard way.
Remote Push Notifications: End-to-End Setup
Step 1: Request Permissions and Get the Push Token
The core setup function handles three things: checking device compatibility, requesting notification permissions, and retrieving the Expo push token. Here's the complete implementation:
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { Platform } from "react-native";
async function registerForPushNotificationsAsync(): Promise<string | undefined> {
// Push notifications require a physical device
if (!Device.isDevice) {
console.warn("Push notifications require a physical device");
return undefined;
}
// Set up Android notification channel BEFORE requesting permissions
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "Default",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
// Check existing permissions
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request permissions if not already granted
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
console.warn("Notification permission not granted");
return undefined;
}
// Get the Expo push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId
?? Constants.easConfig?.projectId;
if (!projectId) {
throw new Error("Project ID not found. Run npx eas init to configure.");
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
return tokenData.data;
}
A few critical details worth calling out. First, we create the Android notification channel before requesting permissions — Android 13 won't show the permission prompt until at least one channel exists (yes, really). Second, the projectId lookup accounts for both the newer easConfig and the legacy extra.eas configuration paths. Third, we gracefully handle cases where the user denies permission instead of throwing an error, because your app should keep working fine without notifications.
Step 2: Configure Foreground Notification Behavior
By default, notifications received while the app is in the foreground are silently consumed. That catches a lot of people off guard. You need to explicitly tell Expo how to handle them:
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
Place this call at the top level of your app — outside any component — so it runs before any notification arrives. If you want different behavior for different notification types, you can inspect the notification object inside the handler and return different configurations.
Step 3: Listen for Notifications and Interactions
You need two listeners: one for incoming notifications and one for user interactions (tapping or dismissing):
import { useEffect, useRef } from "react";
export function useNotificationListeners() {
const notificationListener = useRef<Notifications.EventSubscription>();
const responseListener = useRef<Notifications.EventSubscription>();
useEffect(() => {
// Fires when a notification is received while the app is foregrounded
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
const { title, body, data } = notification.request.content;
console.log("Notification received:", title, body, data);
});
// Fires when the user taps on a notification
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
const { data } = response.notification.request.content;
// Navigate to the relevant screen based on notification data
if (data?.screen) {
// router.push(data.screen); // Using Expo Router
}
});
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(
notificationListener.current
);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(
responseListener.current
);
}
};
}, []);
}
Step 4: Send a Push Notification from Your Server
Once your client registers and sends its Expo push token to your backend, you can send notifications through the Expo Push API. Here's a Node.js implementation using the official expo-server-sdk:
import { Expo } from "expo-server-sdk";
const expo = new Expo();
async function sendPushNotification(
pushToken: string,
title: string,
body: string,
data?: Record<string, unknown>
) {
if (!Expo.isExpoPushToken(pushToken)) {
console.error("Invalid Expo push token:", pushToken);
return;
}
const messages = [
{
to: pushToken,
sound: "default" as const,
title,
body,
data: data ?? {},
priority: "high" as const,
},
];
// Send and get push tickets
const tickets = await expo.sendPushNotificationsAsync(messages);
// Collect receipt IDs for checking later
const receiptIds: string[] = [];
for (const ticket of tickets) {
if (ticket.status === "ok" && ticket.id) {
receiptIds.push(ticket.id);
} else if (ticket.status === "error") {
console.error("Push ticket error:", ticket.message);
if (ticket.details?.error === "DeviceNotRegistered") {
// Remove this token from your database
}
}
}
// Check receipts after 15 minutes
return receiptIds;
}
async function checkReceipts(receiptIds: string[]) {
const receipts = await expo.getPushNotificationReceiptsAsync(receiptIds);
for (const [receiptId, receipt] of Object.entries(receipts)) {
if (receipt.status === "ok") {
continue; // Delivered successfully
}
if (receipt.status === "error") {
console.error("Delivery failed:", receipt.message);
if (receipt.details?.error === "DeviceNotRegistered") {
// Remove token from your database
} else if (receipt.details?.error === "MessageRateExceeded") {
// Implement exponential backoff
}
}
}
}
The two-step flow — tickets first, then receipts — is essential and honestly a bit annoying at first. A ticket with status ok means Expo received your notification, not that the user actually got it. You need to check receipts (Expo recommends waiting about 15 minutes) to confirm delivery to Apple or Google's servers. Receipts stick around for at least 24 hours before getting cleaned up.
Local and Scheduled Notifications
Local notifications don't need a server at all. They're created and triggered entirely on-device, which makes them perfect for reminders, timers, alarms, and habit-tracking apps. The expo-notifications library handles both push and local notifications through the same API, which is honestly one of my favorite things about it.
Scheduling a One-Time Notification
The simplest case — fire a notification after a fixed delay:
async function scheduleDelayedNotification(seconds: number) {
const id = await Notifications.scheduleNotificationAsync({
content: {
title: "Reminder",
body: "Time to check your progress!",
sound: "default",
data: { screen: "/progress" },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds,
},
});
return id; // Save this to cancel later if needed
}
Daily Recurring Notifications
Need a notification that fires every day at a specific time? Pretty straightforward:
async function scheduleDailyReminder(hour: number, minute: number) {
const id = await Notifications.scheduleNotificationAsync({
content: {
title: "Daily Check-In",
body: "How are you feeling today?",
sound: "default",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
},
});
return id;
}
Weekly Notifications
Want a notification every Monday at 9 AM? Use the weekly trigger. One thing to watch out for: weekdays use 1 (Sunday) through 7 (Saturday), which is a bit counterintuitive:
async function scheduleWeeklyNotification() {
const id = await Notifications.scheduleNotificationAsync({
content: {
title: "Weekly Review",
body: "Time to review your goals for the week.",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.WEEKLY,
weekday: 2, // Monday
hour: 9,
minute: 0,
},
});
return id;
}
Trigger Types at a Glance
Here's a quick reference for all available trigger types and their platform support:
- TIME_INTERVAL — Fire after N seconds (repeatable). Works on both platforms. On iOS, repeating intervals must be 60 seconds or greater.
- DAILY — Fire every day at a specific hour and minute. Both platforms.
- WEEKLY — Fire every week on a specific weekday, hour, and minute. Both platforms.
- MONTHLY — Fire every month on a specific day, hour, and minute. Both platforms.
- YEARLY — Fire every year on a specific month, day, hour, and minute. Both platforms. Heads up: month is 0-indexed (January = 0).
- CALENDAR — Match arbitrary date components. iOS only.
- DATE — Fire once at an exact date/time. Both platforms.
Managing Scheduled Notifications
Every scheduled notification returns a unique ID. Hang onto it — you'll need it to cancel individual notifications or list all pending ones:
// Cancel a specific notification
await Notifications.cancelScheduledNotificationAsync(notificationId);
// Cancel all scheduled notifications
await Notifications.cancelAllScheduledNotificationsAsync();
// Get all pending notifications
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
console.log("Pending notifications:", scheduled.length);
Android Notification Channels: Getting Them Right
Android 8.0 (API 26) introduced notification channels, and honestly, they're the single most common source of "my notifications aren't showing" bugs. Every notification on Android must be assigned to a channel. If you don't create one, the notification is silently dropped — no error, no warning, just silence. It's maddening.
Creating Channels for Different Notification Types
Don't just create a single "default" channel and call it a day. Group your notifications by purpose so users can control which ones they receive:
async function setupNotificationChannels() {
if (Platform.OS !== "android") return;
// High-priority channel for time-sensitive alerts
await Notifications.setNotificationChannelAsync("urgent", {
name: "Urgent Alerts",
description: "Critical notifications that require immediate attention",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF0000",
sound: "default",
enableLights: true,
enableVibrate: true,
});
// Standard channel for general updates
await Notifications.setNotificationChannelAsync("updates", {
name: "App Updates",
description: "General updates and new content notifications",
importance: Notifications.AndroidImportance.DEFAULT,
sound: "default",
});
// Low-priority channel for marketing/promotional content
await Notifications.setNotificationChannelAsync("promotions", {
name: "Promotions",
description: "Special offers and promotional content",
importance: Notifications.AndroidImportance.LOW,
sound: null,
});
}
Call this function during app initialization — before scheduling any notifications. And here's a critical caveat: once you create a channel, you can only change its name and description after creation. Importance, sound, vibration, and other settings are locked in. If you need to change those, you have to delete the channel and create a new one with a different ID. It's not ideal, but that's the Android API for you.
Specifying a Channel When Sending
From your server, include the channelId in the push payload:
{
"to": "ExponentPushToken[xxxx]",
"title": "Flash Sale!",
"body": "50% off for the next 2 hours",
"channelId": "promotions",
"priority": "default"
}
For local notifications, specify the channel in the content:
await Notifications.scheduleNotificationAsync({
content: {
title: "Order Shipped",
body: "Your package is on its way!",
channelId: "updates",
},
trigger: null, // Send immediately
});
The "Miscellaneous" Channel Pitfall
This one bit me in production. There's a known Android issue where notifications sometimes fall into a system-created "Miscellaneous" category when the app is backgrounded, completely ignoring your custom channel. The "Miscellaneous" channel has "Pop on screen" disabled by default, so these notifications appear silently. The fix is to always explicitly set channelId on every single notification and make sure the channel exists before the notification is scheduled.
Interactive Notifications with Action Buttons
Notification categories let you add action buttons directly to your notifications, so users can respond without opening the app. Think "Accept/Decline" for invitations, "Reply" for messages, or "Snooze" for reminders. They're a really nice touch for UX.
Defining Categories
async function setupNotificationCategories() {
await Notifications.setNotificationCategoryAsync("message", [
{
identifier: "reply",
buttonTitle: "Reply",
textInput: {
submitButtonTitle: "Send",
placeholder: "Type your reply...",
},
},
{
identifier: "mark-read",
buttonTitle: "Mark as Read",
options: {
isDestructive: false,
opensAppToForeground: false,
},
},
]);
await Notifications.setNotificationCategoryAsync("invitation", [
{
identifier: "accept",
buttonTitle: "Accept",
options: { opensAppToForeground: true },
},
{
identifier: "decline",
buttonTitle: "Decline",
options: {
isDestructive: true,
opensAppToForeground: false,
},
},
]);
}
Handling Action Responses
When a user taps an action button, the response listener fires with the action identifier. Here's how you handle each case:
Notifications.addNotificationResponseReceivedListener((response) => {
const actionId = response.actionIdentifier;
const data = response.notification.request.content.data;
switch (actionId) {
case "reply":
const userInput = response.userText;
// Send the reply to your API
sendReply(data.conversationId, userInput);
break;
case "mark-read":
markConversationRead(data.conversationId);
break;
case "accept":
acceptInvitation(data.invitationId);
break;
case "decline":
declineInvitation(data.invitationId);
break;
case Notifications.DEFAULT_ACTION_IDENTIFIER:
// User tapped the notification body itself
navigateToContent(data);
break;
}
});
To trigger a categorized notification, set the categoryIdentifier in the content:
await Notifications.scheduleNotificationAsync({
content: {
title: "New Message from Alice",
body: "Hey, are you coming to the meetup?",
categoryIdentifier: "message",
data: { conversationId: "abc-123" },
},
trigger: null,
});
Background Notification Handling
Sometimes you need to run code when a notification arrives even if your app is completely killed. This is where expo-task-manager comes in — it lets you register a background task that Expo triggers for headless (data-only) notifications.
import * as TaskManager from "expo-task-manager";
import * as Notifications from "expo-notifications";
const BACKGROUND_NOTIFICATION_TASK = "BACKGROUND_NOTIFICATION_TASK";
TaskManager.defineTask(BACKGROUND_NOTIFICATION_TASK, ({ data, error }) => {
if (error) {
console.error("Background notification error:", error);
return;
}
// Process the notification data in the background
const notification = data as Notifications.Notification;
console.log("Background notification:", notification);
// Sync data, update local cache, etc.
});
Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
Register the task at the module's top level — not inside a component — so it's available even when the app isn't in the foreground. This is especially useful for chat apps that need to update the badge count or sync messages silently in the background.
Android 12+ Permission Gotchas
Android 12 and above introduced several permission changes that trip up developers. Here's what you need to know.
Exact Alarm Permission
Starting with Android 12 (API 31), scheduling notifications at exact times requires the SCHEDULE_EXACT_ALARM permission. Without it, notifications may get deferred or dropped entirely when the device enters Doze mode. Add it to your AndroidManifest.xml via your app config plugin or directly:
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
Android 13 Notification Permission
Android 13 (API 33) requires users to explicitly opt in to notifications through a system permission prompt. Here's the tricky part: this prompt will not appear until you've created at least one notification channel. That's exactly why we create channels before calling requestPermissionsAsync() in our setup function. Reverse the order, and the permission dialog simply never shows up on Android 13+ devices. No error message, no clue what's wrong. Just nothing.
Boot Completed Permission
The expo-notifications library automatically adds the RECEIVE_BOOT_COMPLETED permission through its own AndroidManifest.xml. This is needed to restore scheduled notifications after a device restart. You don't need to add this manually — one less thing to worry about.
Production Best Practices
Always Check Push Receipts
This cannot be overstated: you must check push receipts. A push ticket with status ok only means Expo received your payload. The receipt tells you whether Apple or Google actually accepted it. Set up a background job that checks receipts about 15 minutes after each batch send. Skipping this step is how you end up with "notifications work in development but not in production" bugs that are incredibly hard to debug.
Handle Token Lifecycle
Expo push tokens are stable across app upgrades and, on iOS, even across reinstalls. But on Android, a reinstall can generate a new token. So you'll want a token refresh listener:
useEffect(() => {
const subscription = Notifications.addPushTokenListener((newToken) => {
// Update the token on your server
updatePushTokenOnServer(newToken.data);
});
return () => subscription.remove();
}, []);
Rate Limiting
The Expo push service enforces a limit of 600 notifications per second per project and a maximum of 100 notifications per request. Exceed these limits and you'll get a TOO_MANY_REQUESTS error. The good news: the official expo-server-sdk-node package handles automatic rate limiting and exponential backoff for you. Use it instead of writing raw HTTP calls — seriously, don't reinvent this wheel.
Handle DeviceNotRegistered Gracefully
When a user uninstalls your app, you'll eventually receive a DeviceNotRegistered error in push receipts. This error comes from Apple or Google, and it can take an unpredictable amount of time to show up. When you see it, immediately remove that token from your database. Continuing to send to dead tokens wastes resources and can affect your push delivery reputation.
Don't Block on Token Retrieval
Both getDevicePushTokenAsync and getExpoPushTokenAsync can take a surprisingly long time to resolve on iOS, especially in areas with poor connectivity. Never let this block your app's startup. Fetch the token asynchronously and let the app function normally without push capability if the retrieval fails.
Persist Scheduled Notification IDs
If your app schedules local notifications, store the notification IDs in persistent storage (like expo-secure-store or MMKV). This lets you cancel specific reminders later and provides a recovery path if the user reinstalls the app.
Putting It All Together: A Complete Notification Provider
So, let's tie everything together. Here's a production-ready notification context that combines all the pieces we've covered into a single, reusable provider:
import React, { createContext, useContext, useEffect, useState } from "react";
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { Platform } from "react-native";
type NotificationContextType = {
expoPushToken: string | null;
notification: Notifications.Notification | null;
scheduleLocalNotification: (
title: string,
body: string,
seconds: number
) => Promise<string>;
};
const NotificationContext = createContext<NotificationContextType>({
expoPushToken: null,
notification: null,
scheduleLocalNotification: async () => "",
});
// Must be called outside of components
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export function NotificationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
const [notification, setNotification] =
useState<Notifications.Notification | null>(null);
useEffect(() => {
// Set up channels
setupChannels();
// Register for push notifications
registerForPushNotifications().then((token) => {
if (token) {
setExpoPushToken(token);
// Send token to your server here
}
});
// Notification received listener
const notifSub = Notifications.addNotificationReceivedListener(
setNotification
);
// Notification response listener
const responseSub =
Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data;
// Handle navigation based on data
});
// Token refresh listener
const tokenSub = Notifications.addPushTokenListener((token) => {
// Update token on your server
});
return () => {
notifSub.remove();
responseSub.remove();
tokenSub.remove();
};
}, []);
const scheduleLocalNotification = async (
title: string,
body: string,
seconds: number
) => {
return Notifications.scheduleNotificationAsync({
content: { title, body, sound: "default" },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds,
},
});
};
return (
<NotificationContext.Provider
value={{ expoPushToken, notification, scheduleLocalNotification }}
>
{children}
</NotificationContext.Provider>
);
}
async function setupChannels() {
if (Platform.OS !== "android") return;
await Notifications.setNotificationChannelAsync("default", {
name: "Default",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
});
}
async function registerForPushNotifications() {
if (!Device.isDevice) return undefined;
const { status: existing } = await Notifications.getPermissionsAsync();
let finalStatus = existing;
if (existing !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") return undefined;
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) return undefined;
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data;
}
export const useNotifications = () => useContext(NotificationContext);
Wrap your app with this provider in your root layout, and you've got a clean, reusable notification system that handles push tokens, foreground notifications, user interactions, and local scheduling through a single context. Not bad for a few hundred lines of code.
Frequently Asked Questions
Do push notifications work in Expo Go?
No. As of Expo SDK 53 (released in 2025), push notifications are no longer supported in Expo Go on Android. They were deprecated in SDK 52. You need a development build (expo-dev-client) for remote push notifications. Local notifications still work in Expo Go for prototyping, though.
Why are my Android notifications not showing up?
Nine times out of ten, it's a missing notification channel. Android 8.0+ silently drops notifications without a channel — no error, no log, nothing. Make sure you call setNotificationChannelAsync before scheduling or receiving any notifications. Also verify that the channel's importance is set to HIGH if you want heads-up (pop-on-screen) behavior. Another common culprit is the "Miscellaneous" channel bug where backgrounded notifications ignore custom channels — always explicitly set channelId on every notification.
How do I test push notifications during development?
You need a physical device — push notifications don't work on emulators or simulators. Create a development build with npx expo run:ios or npx expo run:android. You can test the push delivery itself using the Expo Push Notification Tool in your browser, which lets you send test notifications by pasting your Expo push token. For local notifications, you can test directly in Expo Go.
Can I schedule repeating notifications on both iOS and Android?
Yes, but with caveats. The DAILY, WEEKLY, MONTHLY, and YEARLY trigger types work on both platforms. The TIME_INTERVAL trigger also works on both, but repeating intervals on iOS must be 60 seconds or longer. The CALENDAR trigger type is iOS-only — on Android it'll throw an error. For cross-platform repeating schedules, stick to the named triggers (DAILY, WEEKLY, etc.).
How do I handle notification permissions being denied?
Never block app functionality behind notification permissions. If the user denies, gracefully degrade — disable notification-dependent features in the UI and provide a way to re-enable them later. On iOS, once denied, you can't prompt again from the app; you need to direct users to Settings. On Android 13+, you can call requestPermissionsAsync() again, but the system limits repeated prompts. The best approach is to explain the value of notifications before requesting permission — a pre-permission screen that says "we'll notify you when your order ships" converts way better than a cold system prompt.