Today I Learned: Push Notifications in Expo React Native
A practical walkthrough of Expo push notifications: permissions, Android channels, Expo push tokens, EAS credentials, listeners, testing, and lessons learned.

Today I learned that push notifications in Expo are not just one API call. A dependable implementation is a small delivery system: ask for permission at the right time, configure each platform, obtain and store a token, send through a trusted server, and respond correctly when a user taps a notification.
The mental model: local and remote notifications
Local notifications are scheduled by the application on the device. Remote push notifications begin on a server, travel through the Expo Push Service or directly through FCM and APNs, and are delivered by the operating system. The expo-notifications library can handle both, but remote push testing needs a development build.
1. Install and configure expo-notifications
npx expo install expo-notifications expo-constantsThe config plugin applies notification settings during the native build. Because these are build-time settings, rebuild the application after changing the plugin, Android icon, color, channel, or native credentials.
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#ffeb3b",
"defaultChannel": "default"
}
]
]
}
}2. Request permission and create the Android channel
On Android, create the notification channel before asking for permission. This ordering matters on Android 13 because the system prompt does not appear until a channel exists.
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function registerForPushNotifications() {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "Default",
importance: Notifications.AndroidImportance.MAX,
});
}
const current = await Notifications.getPermissionsAsync();
const status = current.status === "granted"
? current.status
: (await Notifications.requestPermissionsAsync()).status;
if (status !== "granted") return null;
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) throw new Error("EAS project ID is missing");
return (
await Notifications.getExpoPushTokenAsync({ projectId })
).data;
}3. Store tokens as device records, not user fields
One user can sign in on several devices, and push tokens can change. I learned to store a record for each installation with the user ID, push token, platform, app version, enabled state, and last-seen time. The app should also listen for token changes and update the server instead of assuming a token lasts forever.
4. Send from a server and inspect receipts
await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
to: expoPushToken,
title: "Order update",
body: "Your order is now in transit.",
data: { screen: "OrderDetails", orderId: "order_123" },
}),
});The send response is only a ticket showing that Expo accepted the message. Production systems should later check push receipts, remove tokens reported as unregistered, retry temporary failures with backoff, and avoid sending sensitive information in the visible notification body.
5. Handle receipt and user interaction separately
useEffect(() => {
const received = Notifications.addNotificationReceivedListener(
notification => {
console.log("Received", notification.request.content.data);
},
);
const responded = Notifications.addNotificationResponseReceivedListener(
response => {
const data = response.notification.request.content.data;
// Validate data, then navigate to the intended screen.
},
);
return () => {
received.remove();
responded.remove();
};
}, []);Receiving a notification while the app is open and tapping one from the notification tray are different events. I now test foreground, background, and terminated states independently, including malformed or stale navigation data.
What I learned from the implementation
- Ask for permission after explaining the value, not immediately on first launch.
- Treat push tokens as rotating device credentials and never as permanent user identifiers.
- Keep notification data small, validate it before navigation, and fetch fresh private data after the app opens.
- Use delivery receipts to clean invalid tokens and distinguish accepted messages from delivered notifications.
- Test real builds and release behavior because native credentials and debug behavior can differ.
Frequently asked questions
Can Expo push notifications be tested in Expo Go?
Local notifications can still work in Expo Go, but remote push notifications require a development build. On Android, remote push support was removed from Expo Go starting with SDK 53.
Why does Android need a notification channel before permission is requested?
On Android 13, the system permission prompt does not appear until the app has created at least one notification channel. Create the channel before calling the permission API.
What is the difference between an Expo push token and a native device token?
An Expo push token is used with the Expo Push Service. A native device token is used when your backend communicates directly with FCM for Android or APNs for iOS.


