Skip to content
OTFotf
All posts

Expo push notifications arrive reliably when tokens, receipts, and retries are wired right

D
DaveAuthor
9 min read
Expo push notifications arrive reliably when tokens, receipts, and retries are wired right

Push notifications are the one feature that fails silently. When a chat message, a delivery update, or a reminder never arrives, the user does not file a bug report. They just stop trusting the app. And because the failure happens somewhere between your server, Expo's push service, Apple, Google, and the device in someone's pocket, debugging it in production feels like chasing a ghost.

The good news is that nearly every push failure comes from a short list of causes: missing permission, a stale token, a send from the wrong place, an unchecked receipt, or a retry strategy that gives up too early. Handle each one deliberately and notifications become one of the most reliable parts of your stack instead of the flakiest.

This guide walks through the full production pattern for Expo push notifications: permission, token storage, backend sending, receipt handling, retries, tap handling, and device testing.

Ask for permission at the right moment

On iOS, the system permission prompt appears exactly once. If the user taps "Don't Allow" because your app asked on first launch before showing any value, you cannot ask again — the user has to dig through Settings to re-enable notifications, and almost nobody does that.

Ask after the user has a reason to say yes. If your app has order tracking, ask when the first order is placed. If it has messaging, ask when the user joins their first conversation. Explain what they will receive and why it matters, in your own screen, before triggering the system dialog. That pre-prompt screen is cheap to build and it measurably changes opt-in rates.

On Android 13 and above, notification permission is also runtime, so the same timing logic applies. Request it with the same care:

import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

async function ensurePushPermission(): Promise<boolean> {
  const current = await Notifications.getPermissionsAsync();
  if (current.granted) return true;
  const requested = await Notifications.requestPermissionsAsync({
    ios: { allowAlert: true, allowBadge: true, allowSound: true },
  });
  return requested.granted;
}

Always check the existing status first and never loop the request. If permission is denied, show a settings shortcut instead of another prompt. Respecting the "no" is what keeps the "yes" meaningful later.

Store tokens like they expire, because they do

The Expo push token is the address your backend sends to. It looks stable, and that stability is a trap. Tokens change when the user reinstalls the app, restores a backup, clears data, or when the underlying native token rotates. Any send table that treats tokens as permanent slowly fills with dead addresses, and your delivery rate decays month after month without a single error in your logs.

Fetch the token after permission is granted, tie it to the signed-in user and the device, and refresh it on every app start:

import * as Device from 'expo-device';

async function registerPushToken(userId: string): Promise<void> {
  if (!Device.isDevice) return;
  const granted = await ensurePushPermission();
  if (!granted) return;
  const token = (await Notifications.getExpoPushTokenAsync()).data;
  await saveTokenToBackend(userId, token);
}

Notifications.addPushTokenListener(async (token) => {
  await saveTokenToBackend(currentUserId, token.data);
});

The push token listener is the part most apps skip. It fires when the native token rotates, and without it you will not learn about the new address until sends start failing. On the backend, store one row per user-plus-device, update the token in place when it changes, and delete the row on sign-out. A token table keyed only by user, with no device dimension, will eventually send one user's notifications to a device they sold.

One codebase. iOS, Android, and web.

The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.

See the live demo

Send through your backend, never the client

Every push send must originate from your server. Embedding send logic in the client means shipping credentials or API access to code the user controls, and it means sends stop working the moment the app is backgrounded — exactly when you need them most.

Your backend holds the token table, builds the message payload, calls the Expo push API, and records the receipt ID that comes back. That record is what makes everything downstream possible: receipt checks, retries, and delivery debugging all start from a sends table with timestamps, payloads, and statuses.

Keep the payload small and intentional. Title, body, a data object for routing, and optionally badge count and sound. Everything the app needs to route the tap should live in the data field, because the visible text is for the human and the data object is for the code:

const message = {
  to: expoPushToken,
  sound: 'default',
  title: 'Your order is on its way',
  body: 'The courier picked up your package.',
  data: { screen: 'orders', orderId: 'ord_123' },
};

One more rule that saves real incidents: never fan out sends synchronously inside a request handler. A "notify all followers" action that awaits hundreds of push API calls will time out the request and send half the batch. Enqueue the work and return to the user immediately.

Read every receipt and honor the errors

The Expo push API answers in two stages, and confusing them is the most common production bug. The send call returns a ticket, which only means the message was accepted. Actual delivery status arrives later, when you query the receipt for that ticket ID. An app that sends but never checks receipts is flying blind — it cannot distinguish delivered from dropped.

Fetch receipts in batches after a short delay, then act on each status. The error names tell you exactly what to do:

async function checkReceipts(ticketIds: string[]) {
  const res = await fetch('https://exp.host/--/api/v2/push/getReceipts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ids: ticketIds }),
  });
  const { data } = await res.json();
  for (const [ticketId, receipt] of Object.entries<any>(data)) {
    if (receipt.status === 'error') {
      if (receipt.details?.error === 'DeviceNotRegistered') {
        await deleteTokenForTicket(ticketId);
      } else {
        await markForRetry(ticketId, receipt.message);
      }
    } else {
      await markDelivered(ticketId);
    }
  }
}

DeviceNotRegistered is the important one. It means the token is dead and will never come back — delete it immediately instead of retrying it forever. Dead tokens are the main reason push bills and send volumes grow while real delivery shrinks. Other errors, like rate limits or transient service faults, go to the retry queue. Treat the receipt check as a janitor that runs continuously: every send produces a ticket, every ticket gets a receipt lookup, and every receipt either confirms delivery or triggers the correct cleanup.

Retry with a queue, not a loop

Transient failures are normal in push delivery — rate limits, brief outages, throttled devices. What separates reliable apps from flaky ones is not avoiding these failures but absorbing them with a retry queue that has backoff, a cap, and a dead letter path.

The pattern is straightforward. Failed sends that are retryable go into a queue with a next-attempt timestamp. A worker picks them up on schedule, waits progressively longer between attempts, and after a fixed number of tries moves the message to a dead letter record that a human can inspect. This keeps one bad token or one slow afternoon from blocking the whole pipeline, and it gives you a bounded, observable system instead of an await inside a for loop with your fingers crossed.

Size the queue worker independently from your web tier so a notification surge never starves interactive requests. And log every state transition — queued, attempted, delivered, dead — because the first time someone asks "did the user get notified," that log is the only honest answer you will have.

Handle the tap, not just the ping

A notification that opens the wrong screen is almost worse than none at all. The tap handler is where the data payload you sent earlier earns its keep: read the routing fields, resolve them against your navigation state, and land the user exactly where the message promised.

The tricky part is authentication state. A tap can cold-start the app, which means the session may not be restored yet when the handler runs. Hold the routing intent until auth resolves, then navigate — exactly the same guard discipline you would use for deep links into protected screens. If your app already structures auth guards that survive expired sessions and deep links, reuse that machinery for notification taps rather than building a second path. One routing pipeline, fed by both links and pushes, is far easier to test than two parallel ones.

Also handle the foreground case. When the app is open, the system does not show a banner by default — the notification arrives silently to your listener. Decide deliberately what happens: update the badge, show an in-app banner, refresh the relevant screen. Doing nothing looks like a bug to the user who just watched a message arrive on their friend's phone but not in the app they are holding.

Test on real devices across states

Push notifications cannot be fully tested in a simulator. iOS simulators do support pushes in recent Xcode versions, but permission flows, background delivery, badge counts, and sound behavior all differ from hardware in ways that matter. Budget for two physical devices — one iPhone, one Android — before you call the feature done.

Walk through a matrix: app in foreground, background, and fully killed; device online and offline at send time; permission granted, denied, and revoked mid-session; token rotated via reinstall. For each state, verify the visible notification, the tap target, and the receipt status in your sends table. This takes an afternoon and it catches the exact class of bugs that otherwise surface as one-star reviews saying "never got the alert."

Keep a staging project that points at the same code but a separate token table so testing never sends to production users. The embarrassment of a test ping reaching ten thousand real devices is entirely preventable with a project split.

Start with the boring parts

Teams usually build push features in the exciting order: craft the message, wire the tap, admire the banner. Production rewards the reverse order. Permission timing first, then token storage with rotation handling, then backend sends with a queue, then receipts with dead-token cleanup, and only then the polish of sounds, badges, and rich content.

Each layer is unglamorous and each one is load-bearing. An app with perfect banners but no receipt handling will quietly rot. An app with boring banners but airtight token hygiene will deliver every time. Build the plumbing, verify it with receipts, and the visible parts will hold up under real users.

Sources

react-nativecross-platformbackend
OTF Fitness Kit

Stop wiring. Start shipping.

  • Login, database, and backend already connected — nothing to set up
  • iOS + Android + web from one codebase
  • AI configs pre-tuned + 40+ tested prompts included