"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 ran into this myself while running an A/B notification test for one of my healing apps, and it always traces back to the same spot: iOS treats foreground notifications as opt-in, and your app has to ask for them explicitly.
I have been shipping indie apps since 2014 with around fifty million downloads across the lineup, and the expo-notifications surface area shifts quietly between SDK versions. Every time I upgrade, this is the trap I rediscover. 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 to be certain:
- 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 logged in history.
If those three match, the problem is almost always a missing foreground handler or the outdated iOS 13 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 drops foreground notifications by default. expo-notifications lets you opt in through setNotificationHandler, which has 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,
}),
});Place 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 may slip through before the effect fires.
Cause 2: Still using shouldShowAlert (deprecated on iOS 14+)
A lot of samples online still look like this:
// Old API — iOS 14+ logs a deprecation warning and the new keys are recommended
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),iOS 14 split notification surfaces into banner (top of screen) and list (Notification Center, lock screen), so a single shouldShowAlert can no longer control both. From expo-notifications v0.27 onward, shouldShowBanner and shouldShowList are the recommended pair. Older versions silently fall back to the legacy keys, which masks the issue but bites you the moment you upgrade.
Cause 3: Permission is stuck in provisional
A common mistake is trusting only the result of getPermissionsAsync() without ever calling requestPermissionsAsync(). iOS can return a provisional status on first install — quiet notifications are allowed, but no foreground banner.
import * as Notifications from "expo-notifications";
async function ensureNotificationPermission() {
const current = await Notifications.getPermissionsAsync();
// provisional reports granted too, so we have to inspect ios.status
if (
current.status === "granted" &&
current.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED
) {
return true;
}
const requested = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowSound: true,
allowBadge: true,
allowDisplayInCarPlay: false,
allowAnnouncements: false,
},
});
return requested.status === "granted";
}Checking ios.status is the critical step. Android tells you everything through granted, but iOS exposes a finer grain — distinguishing AUTHORIZED from EPHEMERAL from PROVISIONAL actually matters here.
Cause 4: The payload is too thin to render
If you return shouldShowBanner: true but the server sends a payload with no title or empty body, iOS quietly drops the visual. The minimum I keep in production looks like this:
{
"to": "ExponentPushToken[xxxxxxxx]",
"title": "New wallpapers added",
"body": "Tap to see today's pick",
"sound": "default",
"badge": 1,
"_displayInForeground": true
}_displayInForeground is an Expo-specific flag and is redundant once setNotificationHandler is wired up, but it keeps mixed-SDK environments predictable. I leave it in place for older clients that have not been rebuilt yet.
Cause 5: A Focus mode is filtering you out
On iOS 15 and later, if your app is not in the "Allowed Apps" list for the user's active Focus, foreground notifications are silenced too. Check Settings → Focus → (mode) → Allowed Apps on your test device. Users who switch between several Focus profiles often forget to add new apps to each one.
This is not strictly a developer-side bug, but the same support questions land in your inbox eventually. Keep this as the first triage question for end-user reports.
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" },
trigger: { seconds: 3 },
});
}}
/>
</View>
);
}If this does not produce a banner, the cause is upstream of the handler — permissions or Focus mode rather than the display flags.
Preventive checklist
- Call
setNotificationHandlerat the top of your root layout. - Use the iOS 14+ keys (
shouldShowBannerandshouldShowList) only. - Inspect
ios.statusinstead of trustinggrantedalone. - Hide a "fire local push" button somewhere in your settings screen so you can triage user reports inside minutes.
For my own wallpaper apps, the moment a release-only notification bug shows up I drop this debug screen into TestFlight and watch the first few hours of telemetry. Knowing whether the problem lives in the app, the payload, or the device almost always shortens the loop to a single day.
Thanks for reading — I hope this saves you a few hours of the same investigation.