HomeBlogExpo Push Notifications: Practical Setup, Caveats, and Troubleshooting

Expo Push Notifications: Practical Setup, Caveats, and Troubleshooting

Ship expo push notifications reliably: token lifecycle, real-device testing, APNS/FCM setup, tap navigation, and a practical troubleshooting checklist.

Expo Push Notifications: Practical Setup, Caveats, and Troubleshooting

Expo push notifications are often the fastest path to React Native push notifications when you want one workflow for iOS (APNS) and Android (FCM). But in production, “it works on my phone” is not enough: tokens rotate, Expo Go has limitations, background behavior differs by OS, and misconfigured credentials cause the most common push token errors.

This guide is written for technical PMs and engineering leads who need a reliable plan: what to implement, what to test, what to monitor, and where teams get surprised.

What Expo push notifications are (and what they are not)

Expo’s push system is a relay:

  • Your app obtains an Expo push token.
  • Your backend (or a trusted service) sends a payload to Expo’s push endpoint.
  • Expo forwards to platform providers:
  • Apple Push Notification service (APNS) for iOS
  • Firebase Cloud Messaging (FCM) for Android

This gives you a unified send format and avoids writing separate APNS and FCM senders. The trade-off is operational: you must manage token lifecycle correctly, keep credentials valid, and design for platform differences.

Two important clarifications:

  • Expo push notifications are not the same as local notifications. Local notifications are scheduled or triggered on-device and don’t require your server.
  • Expo push tokens are not the same as APNS device tokens or FCM registration tokens. They are identifiers that Expo maps to the underlying provider tokens.

External references:

Quick decision matrix (product + engineering)

Use this matrix to align stakeholders before implementation.

When Expo push notifications are a good default

  • You want to ship cross-platform push quickly with one sending API.
  • Your team is already using Expo (managed or prebuild) and prefers minimal native work.
  • Your push volume is moderate and your team can accept a relay dependency (Expo service).

When you should plan for more infrastructure early

  • You need strict SLAs, advanced observability, or dedicated delivery control.
  • You send very high throughput (campaign bursts), or you must guarantee no drops.
  • You need deeper control over credential rotation policies, multi-tenant apps, or compliance constraints.

This doesn’t mean “don’t use Expo” - it means plan how you’ll store tokens, retry sends, process receipts, and handle invalid tokens from day one.

Prerequisites that determine whether your tests will be meaningful

1) Test on real devices (and understand Expo Go limitations)

For push notifications, you need a physical device. Emulators/simulators don’t represent real push delivery behavior.

Also note a key gotcha: Expo Go does not support push notifications as of newer SDK versions (SDK 54+). The practical outcome is:

  • Use a development build (custom dev client) to test push reliably.
  • Use a standalone build (EAS Build) for end-to-end production-like testing.

Source: Expo “What you need to know”: https://docs.expo.dev/push-notifications/what-you-need-to-know/

2) iOS: APNS requires Apple Developer configuration

To ship iOS push in a standalone build, you typically need:

  • An Apple Developer account
  • Push capability enabled for the app identifier
  • Valid signing and provisioning setup

If the team can’t access Apple Developer credentials, push timelines slip-so surface this early.

APNS overview (Apple): https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server

3) Android: FCM setup is mandatory for standalone builds

For Android standalone builds, you must configure FCM credentials so that Expo (and your app) can receive pushes.

  • Ensure your package identifier, Firebase project, and credentials align.
  • Plan for Android 13+ runtime notification permission (POST_NOTIFICATIONS).

Firebase Cloud Messaging documentation: https://firebase.google.com/docs/cloud-messaging

Expo FCM setup guidance: https://docs.expo.dev/push-notifications/push-notifications-setup/

Implementation plan: the 3 flows you must ship

A complete push notification tutorial (the production-ready version) is really three flows:

  1. Register and store tokens (per user, per device)
  2. Send notifications (from backend or trusted service)
  3. Receive + handle interaction (foreground, background, killed)

The biggest reliability issues come from teams shipping flow #2 while under-building flows #1 and #3.

Flow 1: Getting an Expo push token (and avoiding token lifecycle failures)

The minimum registration flow

On first launch (and again after reinstall / permission change):

  • Request notification permission (iOS always; Android depends on version-Android 13+ requires runtime permission).
  • Retrieve the Expo push token.
  • Associate that token with:
  • user ID (if logged in)
  • device ID (or installation ID)
  • platform (iOS/Android)
  • app version / build number (useful for debugging)
  • “last seen” timestamp

From an API standpoint, most teams use Notifications.getExpoPushTokenAsync().

Expo notifications API reference: https://docs.expo.dev/versions/latest/sdk/notifications/

Token lifecycle policy (must-have)

Token lifecycle is where real systems drift.

Recommended policy (matches your requirements and prevents common “wrong user got a push” incidents):

  • On sign-in: upsert the current token for that user + device.
  • On sign-out: delete or deactivate that token association.
  • On app start: refresh token if needed and update “last seen.”
  • On uninstall / invalid token: mark as inactive when you get provider feedback (see “receipts” below).

Why this matters:

  • Reinstalls frequently generate new tokens.
  • Users share devices or switch accounts.
  • Stale tokens inflate send costs and cause noisy error rates.

If you’re seeing “push token errors” in your logs, a stale token table is often the root cause.

Data model suggestion (simple but durable)

A practical schema for your backend:

  • push_tokens table/collection
  • userId (nullable if you support logged-out notifications)
  • expoPushToken
  • platform
  • deviceId
  • createdAt, updatedAt, lastSeenAt
  • status: active | inactive | invalid
  • invalidReason: receipt error, user revoked permission, etc.

This structure supports clean migrations and prevents “token soup.”

Flow 2: Sending notifications (app vs backend vs provider)

Option A: Send directly from the app (generally avoid in production)

You can send to Expo’s push endpoint from the client, but it’s rarely a good production choice:

  • Exposes your push logic and potentially sensitive targeting rules
  • Hard to secure (rate limiting, abuse prevention)
  • No reliable server-side audit trail

Use client-side sends only for prototypes or internal tools.

Option B: Send from your backend using Expo’s HTTP endpoint

This is the most common path:

  • Your backend receives a business event (message, purchase, reminder).
  • Your backend looks up target tokens.
  • Your backend posts a payload to Expo.

Product advantage: you keep ownership of user data and targeting logic.

Engineering advantage: you can implement retries, rate limits, and observability.

Option C: Use expo-server-sdk (Node) or server libraries

If your backend is Node.js, Expo’s server SDK can help with:

  • Chunking large batches
  • Handling ticket IDs and receipts
  • Validating token format

Even if you don’t use Node, copy the behavior: chunk sends, store ticket IDs, later fetch receipts.

The operational concept you must implement: tickets and receipts

Expo sends are typically a two-step story:

  • Ticket: immediate acceptance result (queued, or rejected due to obvious issues like malformed token)
  • Receipt: later delivery status (e.g., invalid token, provider error)

If you don’t process receipts, you’ll keep sending to bad tokens forever-creating chronic “delivery issues” that look like random outages.

Payload design tips (reduce cross-platform surprises)

  • Keep payload small and predictable.
  • Treat the notification as a “tap-to-fetch” entry point, not a complete data transport.
  • Put a stable routing object in data (e.g., type, entityId) so you can deep link reliably.
  • Add an idempotency key for deduping in your app and backend.

Android-specific sending caveat: priority/importance

Android delivery behavior depends on device settings and channel importance.

  • For time-sensitive pushes, you typically want “high” delivery urgency.
  • In modern Android, notification channels control importance; users can override it.

Android notification channels documentation: https://developer.android.com/develop/ui/views/notifications/channels

This area is a common source of FCM push notification errors reports from QA (“I only see it when I open the app”). Often the push arrived, but the channel/importance settings suppressed the alert.

Flow 3: Receiving, displaying, and reacting to notifications

This is where “same payload, different behavior” becomes real.

Foreground vs background vs killed: what actually changes

Think in three states:

  • Foreground: app is visible and running
  • By default, many frameworks do not show a system notification banner automatically.
  • You decide whether to show an alert, play a sound, set a badge.
  • Background: app is not visible but may be suspended
  • The OS displays the notification UI.
  • Your JS execution time is limited; background handlers differ by platform.
  • Killed/terminated: app isn’t running
  • The OS can display the notification.
  • Your app generally cannot run custom logic at receipt time.
  • You can react when the user taps the notification to open the app.

This directly impacts product expectations:

  • Badge counts updated “on receipt” are not reliable if you rely on app code.
  • Adding a notification to an in-app inbox at receive-time won’t happen in killed state.

Design implication: if you need an inbox/badge accuracy, back it with server state and sync on app open.

Handling interactions: navigation-on-tap

Two patterns matter most:

  • A listener that fires when a user taps a notification
  • A “last interaction” lookup used at launch to avoid missing the initial tap

Expo provides useLastNotificationResponse, which is particularly useful because it helps you capture the notification that launched the app, even if your listener wiring happens “too late” during startup.

Practical guidance:

  • Put navigation logic close to where your navigation container is initialized.
  • Define a small routing function that maps notification data to screens.
  • Always handle the “cold start” case (killed → tap → open). This is where teams lose deep links.

Expo docs on notification responses: https://docs.expo.dev/versions/latest/sdk/notifications/

Background handling: be realistic

Many teams try to run significant business logic in background handlers. Keep it minimal:

  • Validate payload
  • Store a lightweight marker
  • Schedule a sync

Treat anything more complex as “best effort.” OS background limits differ, and OEM Android builds can be aggressive.

iOS + Android configuration: the parts that break most often

iOS: permissions, environments, and APNS credential mismatch

Common APNS push notification errors patterns:

  • “Works in debug but not TestFlight/production”
  • Usually an APNS environment mismatch (development vs production) or wrong credentials.
  • “Token registered but no alerts shown”
  • User disabled notifications at OS level, or your foreground presentation rules are not configured.

Mitigations:

  • Document which environment each build uses.
  • Reconfirm credentials when rotating certificates/keys.
  • Add a “push diagnostics” screen in your app (permission status, token, last receipt time).

Android: icons, channels, and Android 13 permission

Common causes of “Android push shows silently” or “no icon”:

  • Notification channel created with low importance
  • User changed channel settings
  • Missing or invalid monochrome small icon requirements (white, transparent background)
  • Android 13+ permission not requested

Mitigations:

  • Define channels deliberately (e.g., “messages”, “marketing”, “system”).
  • Keep the default channel high enough for your core use case.
  • Provide in-app settings shortcuts to the OS notification settings page.

Testing plan (QA-ready): what to test and how to avoid false confidence

A strong push testing plan is small but disciplined.

Device matrix

At minimum:

  • iOS: one recent iPhone on the current iOS, plus one on the previous major iOS
  • Android: one Pixel-like device (close to AOSP) and one OEM device (Samsung/Xiaomi/OnePlus)

Build matrix

Test in:

  • Development build (for fast iteration)
  • Production-like build (TestFlight / internal track) to validate credentials and release config

Scenario checklist

Run each scenario with at least two different payload types (e.g., message, reminder):

  • Fresh install → permission prompt → token registration
  • Sign-in → token stored on backend
  • Sign-out → token removed/deactivated
  • App in foreground → notification received → expected UI behavior
  • App backgrounded → notification shown by OS
  • App killed → notification shown → tap opens correct screen
  • User disables notifications in OS settings → app detects and guides remediation
  • Token rotation scenario (reinstall) → backend updates token and old token is invalidated

Observability checklist

Add logging/metrics for:

  • Token registration success rate
  • Send attempts by platform
  • Ticket errors (immediate failures)
  • Receipt errors (delayed failures)
  • Invalid token rate over time

If you can’t answer “what percentage of tokens are invalid this week,” you’ll struggle to maintain reliability.

Push notification troubleshooting: fast diagnosis by symptom

Below is a practical troubleshooting map for common reports.

Symptom: “Nothing arrives on iOS”

Likely causes:

  • Missing APNS credentials/capability
  • Environment mismatch (dev vs prod)
  • User permissions denied
  • Device in Focus/Do Not Disturb (appears “missing”)

Checks:

  • Verify permission status in-app
  • Confirm your build type and APNS environment
  • Validate that your push provider credentials match the app identifier
  • Test with a known-good device and token registered minutes ago

Symptom: “Android receives only when the app is open”

Likely causes:

  • Notification channel importance too low
  • Payload treated as “data-only” and not displayed
  • Battery optimization / OEM restrictions

Checks:

  • Confirm channel importance and user overrides
  • Confirm you are sending a notification that results in a visible alert
  • Test on a Pixel device to rule out OEM background limits

Symptom: “Tap doesn’t navigate to the right screen”

Likely causes:

  • Routing data missing or inconsistent
  • Listener registered too late during cold start

Checks:

  • Ensure payload includes a stable route descriptor (type/id)
  • Use useLastNotificationResponse for cold-start handling
  • Verify navigation container is ready before attempting navigation

Symptom: “Some users get duplicates”

Likely causes:

  • Same user has multiple active tokens (multiple devices or stale tokens)
  • Your backend retries without idempotency

Checks:

  • Deduplicate sends per user per campaign/event
  • Add an idempotency key; suppress duplicates client-side if needed
  • Deactivate tokens on sign-out and on invalid receipt

Symptom: “We see push token errors in logs”

Likely causes:

  • Stale tokens stored after reinstall
  • Tokens stored without user-device uniqueness
  • Receipts not processed, invalid tokens not cleaned

Fix:

  • Implement the token lifecycle policy (sign-in upsert, sign-out delete)
  • Store lastSeenAt and prune inactive tokens
  • Process receipts and mark invalid tokens

Backend architecture notes (for PMs planning reliability and data ownership)

You don’t need a complex system on day one, but you should avoid painting yourself into a corner.

A simple, scalable baseline

  • App registers token → your API stores it
  • A “notification service” endpoint accepts an event and targets users
  • Sends are queued (even a lightweight queue helps)
  • A worker sends in batches and records ticket IDs
  • A receipt worker fetches receipts and invalidates tokens

This structure keeps your core app server responsive and provides a clean path to scale.

Data ownership and compliance

Push targeting data is often sensitive (segments, behavioral triggers). Keep:

  • user-to-token mapping
  • targeting rules
  • campaign audit logs

in systems you control. Use third-party delivery layers carefully and intentionally.

Migration risk and provider planning (avoid lock-in surprises)

Even if you start with Expo’s push relay, plan how you’d migrate if requirements change.

What makes migrations hard

  • Tokens are provider-specific (Expo token vs direct FCM/APNS token)
  • Payload format and receipt semantics differ
  • Deep link routing logic is often tightly coupled to a provider SDK

A migration-friendly approach

  • Abstract “send notification” behind your own API
  • Store a provider field on tokens (e.g., expo vs direct)
  • Keep routing payload in your own schema, then map to provider formats
  • Maintain a rollback plan (dual-send to a small cohort during cutover)

Checklist for evaluating a managed push layer

If you anticipate higher scale or stricter reliability targets, evaluate vendors on:

  • Native APNS + FCM support (and credential onboarding)
  • REST and server-side integration options
  • Receipt processing and invalid token cleanup
  • Throughput guarantees and backpressure behavior
  • Observability (delivery metrics, error reasons, per-platform visibility)
  • Data ownership and exportability

A helpful suggestion if you’re approaching production scale

If your team is spending disproportionate time on push infrastructure (credential rotation, batching, receipt processing, retries, and “why did this drop?” investigations), it can be worth offloading delivery while keeping your user data and business logic in your own backend. In that situation, you might explore SashiDo’s platform (managed Parse-Server compatible backend plus scalable APNS/FCM push delivery) to simplify credential onboarding and production-grade throughput: https://sashido.io/

Conclusion: ship Expo push notifications with fewer surprises

Expo push notifications are a pragmatic way to ship cross-platform messaging fast-but reliability comes from the unglamorous parts: testing on real devices (not Expo Go), storing tokens with a clear lifecycle (upsert on sign-in, delete on sign-out), designing for foreground/background/killed differences, and building a receipts-driven cleanup loop.

If you implement those fundamentals, your expo push notifications system will behave predictably, your team will spend less time firefighting push notification troubleshooting, and you’ll have a cleaner path to evolve toward higher-scale delivery when product demands it.

Find answers to all your questions

Our Frequently Asked Questions section is here to help.

See our FAQs