"Test pushes show up when the app is in the background, but the moment it is in the foreground they vanish without a sound." I reproduced this myself the week I started an A/B notification test in my app, a healing-sounds app I run. I was convinced the payload was at fault, and I spent an evening reading server logs before I accepted the simpler answer: iOS does not display a notification on its own while your app is in front, and your app has to ask for it.
The display side of expo-notifications shifts a little with each SDK upgrade, so this is a trap I end up rediscovering every time I move to a new SDK. The fix is small once you see it. Let me walk you through how to confirm the cause, and what the cleanest code shape looks like today.
Symptoms and how to confirm the cause
Run this quick triage first, so you do not chase the wrong problem:
- Send a push while the app is in the background: the banner appears
- Send the same push with the app open: nothing shows, no sound
- Open Notification Center: the entry is there in history
If those three match, the cause is almost always a missing foreground handler or incomplete display flags. It is not a token, APNs, or FCM problem. Only when the notification is missing from history should you look at certificates, FCM Server Keys, or the scope passed to getExpoPushTokenAsync.
Cause 1: setNotificationHandler is never called
iOS does not show foreground notifications unless the app opts in. expo-notifications gives you that opt-in through setNotificationHandler, and it needs to run before the first render.
// app/_layout.tsx or the topmost entry point
import * as Notifications from "expo-notifications";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});Put it at the top of your root layout, or just before registerRootComponent. Wrapping it in useEffect is risky, because a push that arrives during cold start can slip through before the effect fires. I first had it inside a component, and the symptom was a single missing notification right after launch, which is a miserable thing to reproduce on purpose.
Cause 2: Still using shouldShowAlert
A lot of samples online still look like this:
// Old API: deprecated in newer SDKs, move to shouldShowBanner / shouldShowList
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),iOS 14 split notification surfaces into banner (top of the screen) and list (Notification Center), so a single shouldShowAlert can no longer describe both. On the expo-notifications side, 0.31 (SDK 53) deprecated shouldShowAlert and introduced shouldShowBanner and shouldShowList as the pair to use. An older setup can look healthy while it is only being mapped onto the legacy key underneath. I would rather move to the new keys before an SDK upgrade than after it.
Cause 3: Permission is stuck at provisional
A common mistake is trusting only the result of getPermissionsAsync() without ever calling requestPermissionsAsync(). iOS has a provisional state, where notifications are delivered quietly to Notification Center. It still reports granted in status, which is exactly why it is easy to miss.
import * as Notifications from "expo-notifications";
async function ensureNotificationPermission() {
const current = await Notifications.getPermissionsAsync();
// provisional also reports granted, so inspect ios.status too
if (
current.status === "granted" &&
current.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED
) {
return true;
}
const requested = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowSound: true,
allowBadge: true,
},
});
return requested.status === "granted";
}Checking ios.status is the critical step. On Android, granted tells you everything. iOS exposes a finer grain, and telling AUTHORIZED, PROVISIONAL, and EPHEMERAL apart actually matters here.
Cause 4: The payload is too thin to render
If the handler returns shouldShowBanner: true but the server sends neither a title nor a body, there is simply nothing to draw. The minimum payload for the Expo Push API looks like this:
{
"to": "ExponentPushToken[xxxxxxxx]",
"title": "New wallpapers added",
"body": "Tap to see today's pick",
"sound": "default",
"badge": 1
}You may also see older samples carrying a _displayInForeground flag. That is a leftover from older Expo clients, and with setNotificationHandler in place I do not think you need it.
Cause 5: A Focus mode is filtering you out
On iOS 15 and later, if your app is not in the Allowed Apps list of the user's active Focus, notifications can be held back even while the app is open. Check Settings, then Focus, then each mode, then Allowed Apps on your test device. If you switch between several Focus profiles, check every one of them.
This is not something you can fix on the developer side, but the same question will eventually reach your support inbox, and it makes a good first triage question for user reports.
A category you never registered
If your app uses setNotificationCategoryAsync for action buttons, a categoryIdentifier that the server sends but the app never registered may make display behavior unreliable. When the failure is intermittent, I look here first.
await Notifications.setNotificationCategoryAsync("daily_pick", [
{ identifier: "save", buttonTitle: "Save", options: { opensAppToForeground: false } },
{ identifier: "open", buttonTitle: "Open", options: { opensAppToForeground: true } },
]);I ran into this in my app while switching categories during an A/B test. Because only the server changed, the notification disappeared for users on older builds alone, which is a very hard bug to observe. A category added on the server ships in the app first, and is used second. I try never to break that order.
A minimal reproduction snippet
Here is everything stitched together as a screen that fires a local notification three seconds after a tap. It removes the server from the loop entirely.
import * as Notifications from "expo-notifications";
import { Button, View } from "react-native";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export default function DebugScreen() {
return (
<View>
<Button
title="Fire local push in 3s"
onPress={async () => {
const perm = await Notifications.requestPermissionsAsync();
if (perm.status !== "granted") return;
await Notifications.scheduleNotificationAsync({
content: { title: "Test", body: "Foreground check", sound: "default" },
// recent versions expect an explicit trigger type
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 3,
},
});
}}
/>
</View>
);
}If this does not produce a banner, the cause sits upstream of the handler: permissions or Focus mode rather than the display flags.
Confirm the handler is actually being called
If you have called setNotificationHandler and nothing changes, put a console.log inside the handler and watch the Xcode console.
Notifications.setNotificationHandler({
handleNotification: async (notification) => {
console.log("[notify] foreground", notification.request.content.title);
return {
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
};
},
});If [notify] foreground appears, the handler runs, and the cause is the display flags or the permission. If it never appears, the handler is registered too late, or the expo-notifications import is missing. On a real device, a Development Build is easier here than TestFlight, since the same output shows up in the Metro log.
Check Android while you are here
On Android, a notification channel whose importance is below HIGH will not show a heads-up banner in the foreground. Many people only notice this after fixing iOS, so I add a minimal channel setup at the same time.
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
if (Platform.OS === "android") {
Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
sound: "default",
});
}Android 13 and later also need the runtime permission request. Calling requestPermissionsAsync on both platforms means you do not have to rewrite the flow each time a new OS ships.
Preventive checklist
- Call
setNotificationHandlerat the top of the root layout - Use only the iOS 14+ keys,
shouldShowBannerandshouldShowList - Inspect
ios.statusinstead of trustinggrantedalone - Hide a "fire local push" button in the settings screen, so user reports can be triaged quickly
When a notification bug shows up only in release builds, I drop this debug screen into a TestFlight build and find out within the first few hours whether the problem lives in the app or on the device. Clearing the device side before suspecting the payload narrowed my search far more than I expected, and I only noticed that after stepping into this trap a few times.
Start by checking whether your setNotificationHandler uses shouldShowBanner and shouldShowList. I hope this shortens the road for anyone losing an evening to the same symptom.