I was trying out a notification message on a real Android phone, with Expo Go open, late one evening. The sending tool said "sent." The phone stayed silent.
I suspected the device first. I re-checked the permission, turned off battery saver, and switched from Wi-Fi to mobile data. Nothing arrived. About an hour had gone by before I stopped.
The cause was not the device, and not how I was sending. Expo Go itself no longer receives remote push on Android. When something that worked yesterday fails today, I now look at the tool's rules before I look at my own setup. That evening is where I settled on that order.
If you're stuck at the same spot, here is the order I'd check things in, and the smallest path to a development build.
The short version: remote push is out of scope for Expo Go on Android
Expo's documentation says that from SDK 53, remote push notifications are not available in Expo Go on Android. If you need to test them properly, the official route is a development build.
I haven't verified the iOS side of Expo Go myself, so please defer to the latest official FAQ there. It's the kind of behavior that can shift between versions.
What matters is that "it doesn't arrive" can come from three different causes.
| Symptom | Where to look | How to tell |
|---|---|---|
| The sender reports success, nothing appears on the device | Runtime (is it Expo Go?) | Show the execution environment inside the app |
| Token retrieval throws | projectId, permission, Android credentials | Read the exception message |
| Works in a development build, not in production | Per-build credentials (FCM / APNs) | Check the target build in EAS credentials |
Only the first row is specific to Expo Go. The other two will still be waiting after you switch, so it's worth separating them now.
Check 1: let the code tell you whether this is Expo Go
Judging by how the phone looks invites assumptions. Ask the app instead.
// ExecutionEnvironmentBadge.tsx
// What it solves: confirm on screen whether you're running in Expo Go
import Constants, { ExecutionEnvironment } from 'expo-constants';
import { Text, View } from 'react-native';
export function ExecutionEnvironmentBadge() {
// StoreClient means Expo Go (distinct from bare / standalone)
const isExpoGo =
Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
return (
<View style={{ padding: 8 }}>
<Text>
Runtime: {isExpoGo ? 'Expo Go' : 'development build / production build'}
</Text>
</View>
);
}I use the enum rather than older checks like Constants.appOwnership, because older checks tend to disappear in later versions. The enum puts the basis of the decision in an official type.
If the screen says "Expo Go," stop hunting through notification settings. The tool is what needs to change.
Check 2: surface token errors instead of swallowing them
Even in a development build, the token can fail. The function tells you why, in words, but only if you let it.
// registerForPush.ts
// What it solves: check permission, projectId and the Android channel in one place
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
export async function registerForPush(): Promise<string> {
if (Platform.OS === 'android') {
// On Android 8+, no channel means no visible notification
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const current = await Notifications.getPermissionsAsync();
let status = current.status;
if (status !== 'granted') {
status = (await Notifications.requestPermissionsAsync()).status;
}
if (status !== 'granted') {
throw new Error('Notification permission was not granted (enable it in device settings)');
}
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
throw new Error('projectId not found (did you run eas init?)');
}
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data; // returned as ExponentPushToken[...]
}On the calling side, catch the error and render the message on screen. If it only goes to a log, you end up looking back and forth between the phone and the terminal, and that's where most of the time disappears.
On Android, getting a token can succeed while delivery still needs FCM credentials. Those are set up through EAS credentials.
Check 3: probe the device path with a local notification
Remote push travels a long road: Expo's servers, then FCM or APNs. A local notification, scheduled on the device, skips that road entirely.
// scheduleLocalProbe.ts
// What it solves: confirm permission and channel are alive, without any server
import * as Notifications from 'expo-notifications';
export async function scheduleLocalProbe() {
await Notifications.scheduleNotificationAsync({
content: { title: 'Notification probe', body: 'If this appears in 5s, the device side is healthy' },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 5,
},
});
}If it shows up after five seconds, permission and channel are fine, and you can narrow suspicion to "from the server onward." If it doesn't, permission or channel comes first.
Check the nearest thing first. Device, then app, then server. Keeping that order alone cut my wandering time noticeably.
The shortest move to a development build
Once you've confirmed Expo Go is the cause, move. After the first build, daily work feels close to Expo Go.
# 1) Install the dev client
npx expo install expo-dev-client
# 2) Log in to EAS and link the project (if not done yet)
npx eas-cli login
npx eas-cli init
# 3) Build a development build (Android device example)
npx eas-cli build --profile development --platform androidAdd a development profile to eas.json:
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": { "buildType": "apk" }
},
"production": {}
}
}distribution: "internal" with buildType: "apk" lets you install straight onto a device without going through a store. When the build finishes, you get a QR code and a link; open it on the phone and install.
Starting the dev server differs only slightly from before:
# Start the dev server in a way that connects to the development build
npx expo start --dev-clientNotifications aren't the only thing Expo Go can't test
If notifications tripped you once, expect the same wall elsewhere. Adding a native module that isn't bundled with Expo Go, or wanting to verify app-specific native settings such as URL schemes or permission strings, also calls for a development build.
As an indie developer keeping several apps going, I find it cheaper to decide up front whether a feature fits in Expo Go. On any day I'm touching notifications, purchases, or custom native configuration, I start in a development build from the beginning.
And if Expo Go stops opening your preview because of sign-in, a different set of checks applies; I've written those up in Two logins to check first when Expo Go stops opening your preview.
One next step
One small thing is enough for today. Add ExecutionEnvironmentBadge as a single line on your app's settings screen. If it says "Expo Go," stop searching notification settings and start with npx expo install expo-dev-client.
Deciding the order of suspicion before a notification fails to arrive is a habit I intend to keep.