TL;DR
- A push ticket that says
"ok"only means Expo accepted your message. Whether Apple or Google accepted it is in the receipt, which you have to fetch yourself ~15 minutes later. - Expo Go can't do remote push on Android since SDK 53. Use a development build.
- On Android 13+, create a notification channel before requesting a token, or the permission prompt never appears.
- Store tokens per device, not per user, and hand them over when someone else signs in on the same phone.
- Send from your server with
expo-server-sdk. Never from the app. - Taps that launch a killed app skip your listener. Read
getLastNotificationResponse()on mount.
The quickstart gets a test push onto your phone in about twenty minutes. Then you ship, and things break quietly: half your Android users never see the permission prompt, tokens pile up for phones that uninstalled months ago, a notification tap opens the home screen instead of the order, and your server logs say every send was ok.
Nothing throws. That's the whole problem with push: the happy path is easy and every failure is silent.
Here's the setup I'd use in production. Stack: expo-notifications, Expo Push Service, Expo Router, and a Supabase/Postgres backend. All TypeScript.
The mental model (get this wrong and nothing else helps)
App ──(1) getExpoPushTokenAsync──▶ Expo ──▶ ExponentPushToken[...]
App ──(2) save token─────────────▶ Your backend
Your backend ──(3) POST /push/send───────▶ Expo ──▶ TICKET
Expo ──(4)──▶ FCM (Android) / APNs (iOS) ──▶ device
Your backend ──(5) POST /push/getReceipts──▶ RECEIPT
-
Ticket
ok= Expo got it. That's all. -
Receipt = what FCM/APNs said.
DeviceNotRegistered,InvalidCredentialsand friends live here.
If you only read tickets, you're blind to most failures.
Before you write any code
- No Expo Go. Since SDK 53, remote push doesn't work in Expo Go on Android. Local notifications still do, which is why push seems to "half work." Use a development build.
-
Credentials. Android needs an FCM V1 service account key uploaded to EAS. iOS needs an APNs key;
eas buildoffers to generate one on your first push-enabled build (or runeas credentials). - Test targets. Physical devices, Android emulators with Google Play services, or iOS simulators on Xcode 14+.
1. Install and configure
npx expo install expo-notifications expo-constants
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#4F46E5",
"defaultChannel": "default"
}
]
]
}
}
icon and color are Android-only and build-time, so changing them needs a new build. The icon must be an all-white PNG on transparent (96×96). Use your full-colour app icon and you'll get a white square in the status bar.
2. Channels first, and by intent
Android 8+ users can mute channels individually. Put promos and chat in one channel and a user who's tired of promos mutes your chat too.
// lib/notifications/channels.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export const CHANNELS = {
default: 'default',
messages: 'messages',
orders: 'orders',
promotions: 'promotions',
} as const;
export async function ensureAndroidChannels() {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync(CHANNELS.default, {
name: 'General',
importance: Notifications.AndroidImportance.DEFAULT,
});
await Notifications.setNotificationChannelAsync(CHANNELS.messages, {
name: 'Messages',
importance: Notifications.AndroidImportance.HIGH,
});
await Notifications.setNotificationChannelAsync(CHANNELS.orders, {
name: 'Order updates',
importance: Notifications.AndroidImportance.HIGH,
});
await Notifications.setNotificationChannelAsync(CHANNELS.promotions, {
name: 'Offers & news',
importance: Notifications.AndroidImportance.LOW,
});
}
Three gotchas:
- On Android 13+, the OS permission prompt won't show until at least one channel exists. Call this before requesting a token.
- Send to a
channelIdthe device hasn't created and the notification is silently dropped. Ship the channel in the app before your backend starts using it. - After creation, Android only lets you change a channel's name and description. Choose importance carefully.
3. Don't burn your one iOS prompt
If an iOS user taps "Don't Allow," you can't show the system prompt again. Asking on first launch, before they know why you'd notify them, wastes it.
Show your own soft prompt at a moment of value, and only trigger the real dialog on "yes":
// components/NotificationSoftPrompt.tsx
import { Linking, View, Text, Pressable } from 'react-native';
import * as Notifications from 'expo-notifications';
import { registerPushToken } from '@/lib/notifications/register';
export function NotificationSoftPrompt({ onDone }: { onDone: () => void }) {
async function enable() {
const { status, canAskAgain } = await Notifications.getPermissionsAsync();
if (status !== 'granted' && !canAskAgain) {
// System prompt is gone for good. Settings is the only way back.
await Linking.openSettings();
return onDone();
}
await registerPushToken(); // shows the system prompt if needed
onDone();
}
return (
<View>
<Text>Know the moment your order ships</Text>
<Text>Only order updates and replies to your messages.</Text>
<Pressable onPress={enable}>
<Text>Turn on notifications</Text>
</Pressable>
<Pressable onPress={onDone}>
<Text>Not now</Text>
</Pressable>
</View>
);
}
"Not now" is free; you can ask again later. A system "Don't Allow" is close to permanent.
On iOS, check
ios.statuson the permission response, not just the rootstatus, especially if you use provisional authorization.
4. Get the token without crashing launch
getExpoPushTokenAsync hits Expo's servers, so it fails on bad networks. The docs example alerts and gives up. Retry with backoff instead, and return null rather than throwing:
// lib/notifications/register.ts
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase';
import { ensureAndroidChannels } from './channels';
let currentToken: string | null = null;
async function getExpoPushToken(): Promise<string | null> {
await ensureAndroidChannels(); // must come first on Android 13+
let { status, canAskAgain } = await Notifications.getPermissionsAsync();
if (status !== 'granted' && canAskAgain) {
({ status, canAskAgain } = await Notifications.requestPermissionsAsync());
}
if (status !== 'granted') return null;
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
if (!projectId) throw new Error('EAS projectId is missing from app config');
for (let attempt = 0; attempt < 3; attempt++) {
try {
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data; // "ExponentPushToken[...]"
} catch {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}
return null; // try again next launch
}
export async function registerPushToken() {
const token = await getExpoPushToken();
if (!token) return null;
const { error } = await supabase.rpc('register_push_token', {
p_token: token,
p_platform: Platform.OS,
});
if (error) {
console.warn('Push token registration failed:', error.message);
return null;
}
currentToken = token;
return token;
}
export async function unregisterPushToken() {
if (!currentToken) return;
try {
await supabase.rpc('deactivate_push_token', { p_token: currentToken });
} catch {
// Offline? The next sign-in on this device moves the token anyway.
}
currentToken = null;
}
Call registerPushToken() on every launch once the user is signed in and has already granted permission. It's cheap and keeps last_seen_at honest.
Tokens can also rotate while the app is running:
useEffect(() => {
const sub = Notifications.addPushTokenListener(() => {
registerPushToken();
});
return () => sub.remove();
}, []);
5. A user is not a token
Hot take: a push_token column on users is a bug waiting to happen. Users have phones and tablets, they reinstall, and they share devices. One user has many tokens, and one token can change owners.
create table public.push_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
token text not null unique,
platform text not null check (platform in ('ios', 'android')),
is_active boolean not null default true,
last_seen_at timestamptz not null default now(),
created_at timestamptz not null default now()
);
create index on public.push_tokens (user_id) where is_active;
alter table public.push_tokens enable row level security;
create policy "Users can read their own tokens"
on public.push_tokens for select
using (auth.uid() = user_id);
No insert or update policy, on purpose. When user B signs in on user A's old phone, the token has to move, and a plain upsert fails under RLS because the row belongs to A. Two security definer functions handle it, both scoped to the caller:
create or replace function public.register_push_token(p_token text, p_platform text)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
if auth.uid() is null then
raise exception 'not authenticated';
end if;
insert into public.push_tokens (user_id, token, platform)
values (auth.uid(), p_token, p_platform)
on conflict (token) do update
set user_id = excluded.user_id,
platform = excluded.platform,
is_active = true,
last_seen_at = now();
end;
$$;
create or replace function public.deactivate_push_token(p_token text)
returns void
language sql
security definer
set search_path = public
as $$
update public.push_tokens
set is_active = false
where token = p_token
and user_id = auth.uid();
$$;
Deactivate on sign-out while the session is still valid, since the RPC checks auth.uid():
export async function signOut() {
await unregisterPushToken(); // before the session is gone
await supabase.auth.signOut();
}
Skip this and user A keeps getting notifications on a phone user B is now using.
Side note: if you want to iterate on this schema, the RLS policy and both RPCs locally without running the full Supabase Docker stack, tinbase is an MIT-licensed, Supabase-compatible backend that runs as a single process with real Postgres and RLS, and supabase-js works against it unchanged. It's alpha, so keep it to local dev and push the same migrations to hosted Supabase for prod.
One more table, for the receipts job in step 7:
create table public.push_tickets (
ticket_id text primary key,
token text not null,
created_at timestamptz not null default now(),
checked_at timestamptz
);
6. Send from the server, never the app
The quickstart sends straight from the device with fetch. That's fine for a demo and wrong for production. Your server is where you batch, throttle, log and protect sends.
npm install expo-server-sdk
// server/push/send.ts
import { Expo, type ExpoPushMessage } from 'expo-server-sdk';
import { createClient } from '@supabase/supabase-js';
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
type PushInput = {
title: string;
body: string;
url?: string; // in-app route to open on tap, e.g. "/orders/123"
channelId?: string; // must already exist on the device
};
export async function sendToUser(userId: string, input: PushInput) {
const { data: rows } = await db
.from('push_tokens')
.select('token')
.eq('user_id', userId)
.eq('is_active', true);
const messages: ExpoPushMessage[] = (rows ?? [])
.map((r) => r.token)
.filter((token) => Expo.isExpoPushToken(token))
.map((to) => ({
to,
title: input.title,
body: input.body,
data: input.url ? { url: input.url } : {},
channelId: input.channelId ?? 'default',
sound: 'default',
}));
// Max 100 messages per request; the SDK chunks for you.
for (const chunk of expo.chunkPushNotifications(messages)) {
const tickets = await expo.sendPushNotificationsAsync(chunk);
await Promise.all(
tickets.map(async (ticket, i) => {
const token = chunk[i].to as string;
if (ticket.status === 'ok') {
await db.from('push_tickets').insert({ ticket_id: ticket.id, token });
} else if (ticket.details?.error === 'DeviceNotRegistered') {
await db.from('push_tokens').update({ is_active: false }).eq('token', token);
} else {
console.error('Push ticket error', ticket.message, ticket.details);
}
}),
);
}
}
What matters in there:
- Small payloads. The total must be ≤ 4,096 bytes. Send an ID and a route, then fetch the details in the app.
-
Enhanced push security. By default, anyone with a user's Expo push token can send to it. Turn on enhanced security in the EAS dashboard and pass
accessToken(SDK v3.6.0+). Requests without it then getUNAUTHORIZED. - Secrets stay server-side. The access token and the service role key never go in the app bundle.
7. Check receipts and prune dead tokens
This is the step most tutorials skip. Expo recommends checking receipts ~15 minutes after sending, and receipts are deleted after 24 hours. So run this every 15 minutes:
// server/push/receipts.ts (run every 15 minutes)
import { Expo } from 'expo-server-sdk';
import { createClient } from '@supabase/supabase-js';
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
export async function processReceipts() {
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000).toISOString();
const { data: pending } = await db
.from('push_tickets')
.select('ticket_id, token')
.is('checked_at', null)
.lt('created_at', fifteenMinutesAgo)
.limit(5000);
if (!pending?.length) return;
const tokenByTicket = new Map(pending.map((p) => [p.ticket_id, p.token]));
// Max 1,000 receipt IDs per request; the SDK chunks for you.
for (const ids of expo.chunkPushNotificationReceiptIds([...tokenByTicket.keys()])) {
const receipts = await expo.getPushNotificationReceiptsAsync(ids);
for (const [ticketId, receipt] of Object.entries(receipts)) {
if (receipt.status !== 'error') continue;
const error = receipt.details?.error;
if (error === 'DeviceNotRegistered') {
await db
.from('push_tokens')
.update({ is_active: false })
.eq('token', tokenByTicket.get(ticketId)!);
} else {
console.error(`Receipt ${ticketId}: ${error}`, receipt.message);
}
}
await db
.from('push_tickets')
.update({ checked_at: new Date().toISOString() })
.in('ticket_id', ids);
}
}
What each error means:
| Error | Where | Meaning | Fix |
|---|---|---|---|
DeviceNotRegistered |
Ticket or receipt | App uninstalled or permission revoked | Deactivate the token |
MessageTooBig |
Receipt | Payload over 4,096 bytes | Send an ID, fetch in-app |
MessageRateExceeded |
Receipt | Too many pushes to one device | Exponential backoff for that device |
InvalidCredentials |
Receipt | FCM/APNs credentials revoked or wrong | Re-upload FCM V1 key / eas credentials
|
MismatchSenderId |
Receipt | FCM key and google-services.json from different projects |
Use the same Firebase project for both |
TOO_MANY_REQUESTS |
Whole request | Over 600 notifications/sec per project | Throttle (the Node SDK already does) |
InvalidCredentials means every push on that platform is failing. Page someone; don't just log it.
Also: don't try to test DeviceNotRegistered by uninstalling and sending a minute later. Apple and Google decide a device is gone on their own schedule.
8. Foreground notifications and taps
Foreground: be selective
By default, a notification that arrives while the app is open is not shown. Your handler opts in, and it has 3 seconds to answer or the notification is discarded.
If the user is already on the screen the notification is about, a banner is noise. Track the active route:
// lib/navigation/active-route.ts
let activeRoute: string | null = null;
export const setActiveRoute = (route: string) => {
activeRoute = route;
};
export const getActiveRoute = () => activeRoute;
Taps: cover the cold start
A tap can open the app from foreground, background, or fully killed. In the killed case your listener isn't registered yet when the tap happens, so read the last response on mount and subscribe:
// app/_layout.tsx
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { Slot, router, usePathname, type Href } from 'expo-router';
import { getActiveRoute, setActiveRoute } from '@/lib/navigation/active-route';
Notifications.setNotificationHandler({
handleNotification: async (notification) => {
const url = notification.request.content.data?.url;
const alreadyThere = typeof url === 'string' && url === getActiveRoute();
return {
shouldShowBanner: !alreadyThere,
shouldShowList: true,
shouldPlaySound: !alreadyThere,
shouldSetBadge: false,
};
},
});
function useTrackActiveRoute() {
const pathname = usePathname();
useEffect(() => {
setActiveRoute(pathname);
}, [pathname]);
}
function useNotificationObserver() {
const handledId = useRef<string | null>(null);
useEffect(() => {
function open(response: Notifications.NotificationResponse) {
const id = response.notification.request.identifier;
if (handledId.current === id) return; // no double navigation
handledId.current = id;
const url = response.notification.request.content.data?.url;
// Internal routes only. Never navigate to an arbitrary URL from a payload.
if (typeof url === 'string' && url.startsWith('/')) {
router.push(url as Href);
}
}
const last = Notifications.getLastNotificationResponse();
if (last) open(last); // app was launched by a tap
const sub = Notifications.addNotificationResponseReceivedListener(open);
return () => sub.remove();
}, []);
}
export default function RootLayout() {
useTrackActiveRoute();
useNotificationObserver();
return <Slot />;
}
Notes:
-
shouldShowAlertis deprecated. UseshouldShowBannerandshouldShowList. - The
startsWith('/')check is there because a payload is server data. A buggy or compromised sender shouldn't be able to send users to an external URL. - On Android debug builds, launching from a notification can glitch the splash screen. That's a known debug-only issue, so test cold-start taps with
npx expo run:android --variant release.
Limits cheat sheet
| Limit | Value |
|---|---|
| Messages per send request | 100 |
| Receipt IDs per receipts request | 1,000 |
| Send rate per project | 600 / second |
| Total payload | 4,096 bytes |
| Check receipts | ~15 min after sending |
| Receipts kept for | 24 hours |
| Foreground handler deadline | 3 seconds |
| Default TTL | Provider default (~4 weeks) |
priority defaults to normal on Android and high on iOS, and normal-priority messages can wait on a sleeping Android device. A very low ttl (like 0) can mean a notification never reaches an Android phone in Doze mode. Use high priority and a sensible TTL only when a message is genuinely time-sensitive.
Drop a comment with what you're building and the push bug that cost you the most time. I'm collecting the weird ones for a follow-up on background (headless) notifications.
This article was created with the help of AI. Code, limits and API names were checked against the Expo docs (SDK 57) and Apple's App Review Guidelines.
Top comments (0)