The Definitive Guide to Push Notifications in Expo (iOS + Android in Production)

The Definitive Guide to Push Notifications in Expo (iOS + Android in Production). Master setup, APNs/FCM, send/receive, & production best practices for success.

Profile photo of SanketSanket
19th May 2026
Featured image for The Definitive Guide to Push Notifications in Expo (iOS + Android in Production)

Your Expo app is done. The auth flow works, the UI feels polished, and you've probably already shipped a development build to a phone. Then product asks for push notifications, and the easy part ends.

The transition to production often reveals recurring difficulties. A test notification appears once, everybody celebrates, and then production reality shows up. iOS needs the right Apple credentials. Android needs Firebase wired correctly. Tokens have to be stored, updated, and retired. Foreground behavior doesn't match background behavior. A “works on my device” setup turns into support tickets fast.

The Definitive Guide to Push Notifications in Expo (iOS + Android in Production) is really about that gap. Not the demo. The system. If you're deploying push for the first time in a real Expo app, you need a mental model, a clean client setup, secure credential handling, a sane backend flow, and an operational plan that still works after launch day.

Table of Contents

Starting Your Push Notification Journey

A common first version looks like this: install expo-notifications, ask for permission on app launch, print a token to the console, send one message, and call it done. That's enough to prove the library works. It's not enough to ship.

Production push is a coordination problem. Your app needs permission handling that doesn't annoy users. Your backend needs a reliable place to store tokens. Your deployment process needs working Apple and Google credentials. Your QA flow needs actual devices and platform-aware test cases. If any one of those pieces is weak, the whole feature feels flaky.

The teams that struggle usually aren't bad at mobile. They just treat push like a UI feature instead of a delivery system. Those are different categories of work. A button can fail unnoticed and only affect one screen. A broken notification flow affects onboarding, retention, reminders, alerts, and support load all at once.

Practical rule: If you can't answer where tokens are stored, who owns credentials, and how you test notification taps from a cold start, you're still in prototype mode.

The good news is Expo removes a lot of platform friction. That doesn't mean you can skip the hard parts. It means you can spend your energy on the parts that matter in production: permissions, credentials, routing, payload design, and operational discipline.

Understanding the Push Notification Ecosystem

Push gets easier once you stop thinking of it as a single API call and start thinking of it as a delivery chain.

Your app collects permission and an address. Your backend decides when to send. Expo can act as the intermediary. Apple and Google do final delivery to the device. If any step breaks, the message doesn't arrive.

Who does what

A postal service analogy works well here.

Your backend is the sender. It decides that a user should receive “Your order shipped” or “Your teammate mentioned you.” The Expo Push Service acts like a routing center. It accepts your request and forwards it to the correct platform service. APNs handles iOS delivery. FCM handles Android delivery. The app on the device receives the message and decides how to present or process it.

A diagram illustrating the four steps of the push notification workflow within the Expo mobile ecosystem.A diagram illustrating the four steps of the push notification workflow within the Expo mobile ecosystem.

If you want a clean conceptual companion to that model, this Expo integration overview from AppLighter docs is a useful reference for how Expo sits inside a larger app stack.

Here's the simple flow:

  1. The user opens your app and grants notification permission.
  2. Your app retrieves an ExpoPushToken and sends it to your backend.
  3. Your backend sends a notification request addressed to that token.
  4. Expo routes the request to APNs or FCM, and the platform delivers it to the device.

What an ExpoPushToken actually represents

The ExpoPushToken is the address your backend uses when it wants to reach one installed app instance. It is not a user record, and it is not permanent identity. Treat it like a delivery endpoint that belongs to a specific installation state.

That distinction matters. One user may have multiple tokens across devices. One device may produce a fresh token after reinstall or account changes. If you attach a token too rigidly to a user without lifecycle cleanup, your backend will keep sending to stale destinations.

The healthiest mental model is “user to many installations, installation to one active push address.”

Expo's own documentation also makes an important architectural point. The push notifications API is push-service agnostic, so you can use Expo's approach now and still connect directly to APNs and FCM later if your app needs finer control, as described in Expo's push notification setup documentation.

That flexibility is worth keeping in mind early. It means choosing Expo today doesn't lock you out of a more customized delivery strategy later.

Core Client-Side Setup in Your Expo App

The client setup has one job: ask at the right time, get permission cleanly, fetch the token, and wire app-side handlers so notifications don't feel random.

Install and configure the Expo side

Start with the packages Expo expects:

npx expo install expo-notifications expo-device expo-constants

Then add the notifications plugin to your Expo config.

{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "color": "#302a26"
        }
      ]
    ]
  }
}

A lot of notification bugs are really config drift. Someone installs the package, but the app config doesn't match the build they're testing. Keep the plugin setup in source control and review changes to it like infrastructure.

If you want a broader Expo project baseline before adding push, this Expo React Native tutorial from AppLighter is a decent starting point for the surrounding app structure.

Register permissions and fetch the token

This is the core registration flow I'd use in most Expo apps:

import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';

export async function registerForPushNotificationsAsync() {
  if (!Device.isDevice) {
    return null;
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    return null;
  }

  const projectId =
    Constants.expoConfig?.extra?.eas?.projectId ??
    Constants.easConfig?.projectId;

  if (!projectId) {
    throw new Error('Missing EAS projectId');
  }

  const token = await Notifications.getExpoPushTokenAsync({ projectId });
  return token.data;
}

A few details matter more than they look:

  • Check before prompting: Don't fire the native permission modal the moment the app opens unless your product depends on push immediately.
  • Guard for real devices: Push testing often fails because somebody expects every simulator path to behave the same way.
  • Require the project ID: Missing projectId is one of those configuration issues that wastes an afternoon.

You'll also want foreground presentation behavior defined explicitly:

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

Without this kind of explicit handler, teams often misread foreground behavior as a delivery problem when it's really a presentation decision.

A registration hook that's actually usable

Wrap registration in a hook or service that sends the token to your backend only after a signed-in user is available.

import { useEffect } from 'react';

export function usePushRegistration(userId?: string) {
  useEffect(() => {
    if (!userId) return;

    async function syncPushToken() {
      const token = await registerForPushNotificationsAsync();
      if (!token) return;

      await fetch('/api/push/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, token, platform: 'expo' }),
      });
    }

    syncPushToken().catch(console.error);
  }, [userId]);
}

Don't tie token upload to app startup alone. Tie it to authenticated app state. That keeps your token table aligned with actual users instead of anonymous installs you can't reason about later.

Managing Platform-Specific Production Credentials

A lot of push rollouts look done right up until the first production test on a real device. The token registers, the backend returns 200, Expo accepts the request, and nothing arrives. In practice, that failure is usually a credential problem, not an app code problem.

Treat credentials like deploy-time infrastructure

If your push setup depends on one engineer's laptop downloads folder, it will break at the worst possible time.

Handle push credentials the same way you handle signing keys, API secrets, and release certificates. Give them clear ownership, restricted access, and a repeatable rotation process. In Expo projects, eas credentials is usually the least painful way to keep this under control because it shows what your build pipeline is using and who changed it.

A few rules save a lot of cleanup later:

  • Use team-owned accounts: Apple and Firebase access should belong to the company, not the developer who happened to set up the first build.
  • Assign credential ownership: Someone on the team should be able to replace APNs or FCM credentials without digging through old Slack threads.
  • Separate environments on purpose: Staging and production should not share credentials by accident. That mistake creates misleading test results and painful incident response.

A comparison chart outlining production credentials for iOS APNs and Android FCM push notifications for Expo apps.A comparison chart outlining production credentials for iOS APNs and Android FCM push notifications for Expo apps.

Small release details can also spill into credential work. Renaming an app, changing bundle identifiers, or cleaning up store metadata often forces you to revisit pieces of the push setup, which is why a guide on changing an app name in Expo and native projects ends up being relevant during production hardening.

How to think about APNs and FCM

For iOS, delivery depends on Apple Push Notification service. For Android, it depends on Firebase Cloud Messaging. Expo can broker delivery through its own push service, but the platform trust chain still has to be valid underneath it.

The practical difference shows up in how failures present:

PlatformWhat you need to manageFailure mode when it's wrong
iOSApple-side push credentials and a valid build setupThe app registers fine, but notifications never reach the device
AndroidFirebase project configuration and correct Expo linkageAndroid delivery fails while iOS continues to work
BothConsistent environment setup across buildsA build from last week works, a new one does not, and the code diff looks unrelated

This is the part many happy-path tutorials skip. A green build does not prove push is production-ready. It only proves the app compiled. Real readiness means the credential chain is valid, the installed build can get a usable token, and both platforms can receive notifications under the environments you plan to ship.

One more production gotcha. Credential problems often show up only after a rebuild. A stale dev build can hide a broken APNs or FCM setup because the app binary was created before the credential change. If you update credentials, cut a fresh build and test that build.

Later in the process, this video gives a useful visual walkthrough for the credential side of Expo notifications:

Effective production testing

The test matrix should be boring. Boring is good.

Use a repeatable flow that any engineer or QA person can run without tribal knowledge:

  • Start with a development build: Launch it with npx expo start, open the installed build, confirm the app gets an ExpoPushToken, then send a targeted test notification.
  • Test every app state: Verify foreground delivery, background delivery, and cold start behavior when the user taps the notification.
  • Test iOS and Android independently: One passing path does not validate the other platform's credentials.
  • Re-test after credential changes: Uploading new APNs or FCM credentials without rebuilding leaves teams testing the wrong artifact.

The production lesson is simple. Push readiness comes from a working chain of permissions, token generation, credential setup, build integrity, and delivery tests on supported targets. Anything less is guesswork.

Sending and Handling Notifications End-to-End

Once the client can register and the credentials are in place, the next failure point is usually the seam between your backend and the app.

A minimal backend sender

For Node backends, expo-server-sdk-node is the easiest way to avoid hand-rolling message formatting.

import { Expo } from 'expo-server-sdk';

const expo = new Expo();

export async function sendPushNotification(expoPushToken: string) {
  if (!Expo.isExpoPushToken(expoPushToken)) {
    throw new Error('Invalid Expo push token');
  }

  const messages = [
    {
      to: expoPushToken,
      sound: 'default',
      title: 'Order update',
      body: 'Your order is on the way.',
      data: {
        screen: 'OrderDetails',
        orderId: 'abc123'
      }
    }
  ];

  const chunks = expo.chunkPushNotifications(messages);

  for (const chunk of chunks) {
    await expo.sendPushNotificationsAsync(chunk);
  }
}

That example is intentionally small. In production, don't call this straight from a route handler that responds to user traffic. Put the send request onto a queue or background job and let a worker process it. Notification delivery shouldn't slow down your primary API.

A payload should also be predictable. I like including a screen field and a small, well-defined object of routing data. Don't dump whole records into data. You're not building a cache transport. You're building a wake-up signal plus enough context to guide the user.

Receiving notifications inside the app

On the client, separate two concerns: receipt and response.

  • Receipt means a notification arrived while the app is active.
  • Response means the user tapped the notification.

Those are different UX moments and should have different code paths.

import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';

export function useNotificationListeners() {
  const receivedListener = useRef<Notifications.EventSubscription | null>(null);
  const responseListener = useRef<Notifications.EventSubscription | null>(null);

  useEffect(() => {
    receivedListener.current =
      Notifications.addNotificationReceivedListener(notification => {
        console.log('Received in foreground:', notification);
      });

    responseListener.current =
      Notifications.addNotificationResponseReceivedListener(response => {
        console.log('User tapped notification:', response);
      });

    return () => {
      if (receivedListener.current) {
        Notifications.removeNotificationSubscription(receivedListener.current);
      }
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);
}

Foreground notifications deserve product thought. Sometimes a system banner is fine. Sometimes it's annoying because the user is already looking at the exact screen the notification refers to. In chat apps, support apps, and task apps, many teams get better UX by updating in-app state and showing a custom banner only when context calls for it.

Handling taps and deep links

The tap path is where notifications become useful instead of decorative. If a user opens a push and lands on the wrong screen, trust drops quickly.

A basic deep-link handler might look like this:

function handleNotificationNavigation(response: Notifications.NotificationResponse, router: any) {
  const data = response.notification.request.content.data as {
    screen?: string;
    orderId?: string;
  };

  if (data.screen === 'OrderDetails' && data.orderId) {
    router.push(`/orders/${data.orderId}`);
    return;
  }

  router.push('/notifications');
}

A few rules keep this stable:

  • Validate payload fields: Never assume data.screen is present or valid.
  • Prefer IDs over full objects: Fetch fresh data after navigation when the screen loads.
  • Add a safe fallback: If parsing fails, route to a notification center or inbox screen.

Debugging shortcut: If the push arrives but tapping it behaves strangely, inspect the payload shape before touching your navigation code.

Cold-start behavior matters too. If the app was terminated and launched from a notification, make sure your startup flow can defer navigation until auth and navigation containers are ready. Otherwise you'll get intermittent “works sometimes” reports that are hard to reproduce.

Production Best Practices for a Scalable System

The first version of a push system often works fine in staging, then starts failing in boring, expensive ways in production. A user reinstalls the app and keeps the old token in your database. A campaign send floods the same worker that handles password reset alerts. Support reports that Android delivery looks fine while iOS users stopped receiving anything last week. Production readiness starts there.

Token management is part of system design

Treat push tokens like a changing set of delivery endpoints, not a profile field.

Each user can have multiple active tokens across phones, tablets, work devices, and reinstalls. If your schema stores one token per user and overwrites it on login, you will lose valid destinations and create hard-to-debug delivery gaps. Store tokens in their own table or collection with platform, app/environment, last-seen timestamp, status, and ownership history if your logout flow allows account switching on shared devices.

A healthy token model includes:

  • Multiple tokens per user: Every device is a separate destination.
  • State, not just the raw token: Track active, invalid, opted out, and last confirmed at.
  • Soft invalidation: Mark bad tokens inactive after delivery feedback instead of deleting them immediately.
  • Clear logout rules: Decide whether logout disables the token, removes the user association, or keeps the token available for the next signed-in user on that device.
  • Deduplication on send: Prevent the same user from getting duplicate pushes because the same token was stored twice.

This pays off quickly. Dead tokens waste send capacity, hide real delivery problems, and make campaign metrics look worse than they are.

Throughput limits should shape your backend

Expo is convenient, but it is still an external push gateway with operational limits. A commonly referenced Expo guide from Courier calls out a limit of 600 notifications per second per project in Courier's Expo notifications guide. That matters as soon as product asks for bulk sends, reminders, digests, or announcement blasts.

Do not send high-volume pushes directly inside request handlers, cron jobs, or admin panel actions. Put notification intents onto a queue, then let workers send in controlled batches. That gives you room for retries, backpressure, and prioritization when transactional messages and broadcast traffic compete for the same pipeline.

A checklist infographic outlining six best practices for managing push notifications in a production environment.A checklist infographic outlining six best practices for managing push notifications in a production environment.

A practical sending architecture includes:

  • A queue: App events create notification jobs instead of sending immediately.
  • Priority levels: Password resets, security alerts, and order updates should not wait behind a marketing blast.
  • Batch processing: Group sends so workers can use Expo SDK chunking efficiently.
  • Retry rules: Retry transient failures with limits. Stop retrying clearly invalid tokens.
  • Receipts processing: Read push receipts and feed failures back into token cleanup.
  • Observability: Log accepted sends, provider errors, queue depth, retry counts, and invalidation events.

Small teams do not need a complicated event bus on day one. They do need separation between app logic and delivery logic. That boundary is what keeps a spike in notification volume from turning into an outage.

Delivery success depends on cleanup loops

Sending a push is only half the operation. The other half is processing what happened after send.

Expo tickets tell you whether Expo accepted the message. Receipts help you find messages that later failed downstream. If you never consume receipts, stale tokens steadily accumulate and your system keeps paying to target devices that can no longer receive notifications. Build a scheduled job that checks receipts, classifies failures, deactivates bad tokens, and records enough context for support and engineering to diagnose patterns by platform or app version.

This is also where teams decide whether to stay on Expo long term or move to direct APNs and FCM integration. Expo is a good fit for many apps, especially early on. If you need provider-specific features, tighter control over delivery behavior, or custom infrastructure for very high volume, direct integration can be an advantage. Make that decision based on operational needs, not instinct.

Good push UX reduces technical noise

Bad notification strategy creates fake infrastructure problems. Users disable permissions, mute the app, or ignore alerts, and the team starts chasing “delivery issues” that are really relevance issues.

Useful pushes are specific, timely, and tied to an action the user can take. “Your invoice is ready” beats “You have an update.” “Sam mentioned you in Project Alpha” beats “New activity.” Keep payloads small, put only the routing data you need in them, and avoid alerting users about content they are already viewing in the app.

A production-ready push system is both operationally sound and editorially disciplined. Reliable delivery matters. Sending fewer, better notifications matters just as much.

FAQ and Accelerating Your Launch

Common questions that come up fast

Can I use Firebase directly instead of Expo's push service?
Yes. Expo's documentation says the API is push-service agnostic, which means you can connect directly to APNs and FCM if you need finer control. That choice usually makes sense when you've outgrown the managed flow or need provider-specific behavior.

Why aren't notifications showing up on iOS?
Most iOS failures come from one of four places: permission was denied, credentials are incomplete, you're testing on an environment that doesn't match your build assumptions, or the app's foreground behavior is being misread as a delivery issue. Check the full chain, not just the client code.

How do I handle notifications when the app is killed?
Treat launch-from-notification as its own path. Read the last notification response, wait until auth and navigation are ready, then route. Many “cold start push bugs” are really app startup race conditions.

Should I ask for permission on first launch?
Usually no. Ask when the value is obvious. A reminder app can justify asking earlier than a social app that hasn't yet shown any follow graph or message activity.

A professional man with a beard working on his laptop in a bright and minimalist office space.A professional man with a beard working on his laptop in a bright and minimalist office space.

When to stop building plumbing yourself

A lot of teams don't fail at push because the APIs are impossible. They fail because push sits at the intersection of mobile, backend, auth, environment management, and release process. Every one of those pieces is small in isolation. Together, they become real launch friction.

If you're building an MVP or trying to ship client work quickly, there's a point where wiring all of that from scratch stops being a good use of engineering time. You still need to understand how Expo push works in production, but you don't necessarily need to hand-assemble every surrounding piece if speed matters.

Build custom notification infrastructure when it gives you leverage. Don't build it just because nobody told you there was another option.

A production-ready starter can remove a lot of setup drag around auth, backend wiring, navigation, and the kind of app structure push depends on to feel reliable after the first demo.


If you want to launch faster without rebuilding the same Expo app infrastructure from scratch, take a look at AppLighter. It gives you an opinionated Expo-based starter with the core app plumbing already wired up, which makes production features like push notifications much easier to integrate into a real product instead of a demo.

Stay Updated on the Latest UI Templates and Features

Be the first to know about new React Native UI templates and kits, features, special promotions and exclusive offers by joining our newsletter.