DEV Community

Sarah
Sarah

Posted on Originally published at sarah-dyne.hashnode.dev

Expo Push Notifications in React Native: The Production Guide the Quickstart Skips

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
Enter fullscreen mode Exit fullscreen mode
  • Ticket ok = Expo got it. That's all.
  • Receipt = what FCM/APNs said. DeviceNotRegistered, InvalidCredentials and 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 build offers to generate one on your first push-enabled build (or run eas 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
Enter fullscreen mode Exit fullscreen mode
{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#4F46E5",
          "defaultChannel": "default"
        }
      ]
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

Three gotchas:

  1. On Android 13+, the OS permission prompt won't show until at least one channel exists. Call this before requesting a token.
  2. Send to a channelId the device hasn't created and the notification is silently dropped. Ship the channel in the app before your backend starts using it.
  3. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

"Not now" is free; you can ask again later. A system "Don't Allow" is close to permanent.

On iOS, check ios.status on the permission response, not just the root status, 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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
$$;
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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);
        }
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 get UNAUTHORIZED.
  • 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • shouldShowAlert is deprecated. Use shouldShowBanner and shouldShowList.
  • 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)