Mobile — Expo Development

Expo Push Notifications at Scale

Direct answer

Expo's push service scales fine if you operate it like infrastructure: store tokens server-side with device and user metadata, send in chunks via the server SDK, and — the step almost everyone skips — process push receipts so DeviceNotRegistered tokens get pruned. Most 'Expo push is unreliable at scale' complaints I investigate turn out to be rotting token lists and unread receipts, not the service itself.

Push notifications are trivial to demo with Expo and easy to run badly in production. The gap is entirely operational: token lifecycle, batching, and receipt processing. This is the setup I put in place when a client's notification volume starts mattering.

Key facts, with sources

  • 71% of State of React Native 2024 respondents reported using Expo's EAS Build, ahead of manual builds with Xcode (59.7%) and Android Studio (54.5%). (InfoQ)
  • Expo SDK 52, released November 12, 2024, enabled the New Architecture by default for all new projects, and Expo Go for SDK 52 and higher supports only the New Architecture. (Expo Changelog)
  • The official React Native documentation now recommends starting new apps with a framework, naming Expo, rather than initializing a bare project. (React Native documentation)
  • EAS build caching can speed up subsequent Android and iOS builds by up to 30% and is available to all users at no additional cost as of the SDK 57 cycle. (Expo Changelog)
  • Doctolib runs Expo tooling without EAS to scale developer experience on a healthcare app serving 90 million users, showing Expo's open-source tools work independently of its paid cloud services. (Doctolib Engineering (Medium))

How the pipeline actually works

Expo push is a relay: your server sends messages to Expo's push API, and Expo forwards them through FCM for Android and APNs for iOS using credentials you've configured in your project. The Expo push token abstracts over both platforms, which is the whole value proposition — one token format, one send API, no separate FCM and APNs integration code.

Understanding the relay model explains the operational surface. There are two acknowledgment stages: tickets, returned immediately when Expo accepts your messages, and receipts, available shortly after, which report what happened when Expo handed the message to FCM or APNs. Delivery problems at scale are diagnosed in the receipts, and the platform-level errors they carry are the only signal you get that a device token has died.

Registration done right on the client

Client-side registration has a few production requirements the quickstart glosses over: check for a physical device (simulators can't receive push), request permission at a moment that makes sense in your UX rather than at launch, and pass your EAS project ID when fetching the token. Send the token to your backend keyed by user and device — users have multiple devices, and devices change hands between users on logout.

Re-register on every app launch, not just once. Tokens can rotate, and treating registration as idempotent upsert on the backend costs nothing while silently healing stale records.

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

export async function registerForPush(): Promise<string | null> {
  if (!Device.isDevice) return null;

  const { status } = await Notifications.getPermissionsAsync();
  let final = status;
  if (final !== 'granted') {
    const req = await Notifications.requestPermissionsAsync();
    final = req.status;
  }
  if (final !== 'granted') return null;

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const token = await Notifications.getExpoPushTokenAsync({ projectId });
  return token.data; // upsert to backend keyed by user + device
}

Server sends: chunking, tickets, receipts

The Expo push API accepts batched messages, and the official server SDK handles chunking to the API's limits for you. The pattern that matters: validate tokens with the SDK's checker before sending, persist the tickets you get back, and then — after a delay — fetch receipts for those tickets. A receipt with a DeviceNotRegistered error means that token is dead: the user uninstalled, or the platform invalidated it. Delete it immediately.

Skipping receipt processing is the classic scale failure. Dead tokens accumulate, every campaign wastes send volume on them, and sustained sending to invalidated tokens is exactly the behavior platform push services penalize. Token hygiene isn't optional bookkeeping — it's what keeps delivery rates healthy as your install base ages.

Chunked sends with the Expo server SDK
import { Expo } from 'expo-server-sdk';

const expo = new Expo();

const messages = tokens
  .filter(Expo.isExpoPushToken)
  .map((to) => ({
    to,
    title: 'Order shipped',
    body: 'Your order is on the way.',
    data: { orderId },
  }));

for (const chunk of expo.chunkPushNotifications(messages)) {
  const tickets = await expo.sendPushNotificationsAsync(chunk);
  await persistTickets(tickets);
  // later: fetch receipts, prune DeviceNotRegistered tokens
}

Platform details that bite at volume

On Android, notification channels are mandatory: create them explicitly with the importance levels your product needs, because channel importance controls whether a notification makes sound, appears heads-up, or lands silently — and users can silence a channel forever, so don't funnel everything through one default channel. Separate transactional from promotional channels early; retrofitting after users have muted the only channel is a permanent loss.

On iOS, permission timing dominates opt-in rates. Requesting push on first launch, before the app has demonstrated any value, produces predictably poor acceptance — and a declined iOS prompt can't be re-shown, only routed to Settings. I gate the system prompt behind a product moment where the value of notifications is obvious, often with a soft-ask screen first.

When to graduate off Expo's push service

Expo push covers the standard cases well, but there are legitimate graduation triggers. If you need rich platform-specific payload features the relay doesn't expose, direct APNs and FCM integration gives you the full surface. If you're sending very high volumes with strict latency requirements, removing the relay hop puts you in direct control of throughput and retry behavior. And some compliance regimes require that message content not transit third-party infrastructure.

The migration isn't all-or-nothing: expo-notifications can hand you native device tokens, so you can keep the client library while sending server-side through FCM and APNs directly. Most teams never need this. My advice is to exhaust the operational fixes — receipts, token hygiene, channel strategy — before blaming the transport, because in my experience the transport is rarely the problem.

When to hire senior help

Senior help pays off when deciding between managed and prebuild workflows, setting up EAS-based CI/CD and OTA update channels, or untangling a project that was ejected prematurely. An experienced Expo engineer can usually configure build, submit, and update pipelines in days, a task that costs first-time teams weeks of trial and error. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.

Bottom line

Dhairya Senjaliya ships Mobile — Expo Development projects worldwide — book a scoping call to discuss your specific situation.

Common pitfalls to avoid

  • Ejecting to the bare workflow at the first native requirement instead of using config plugins and development builds, permanently giving up managed upgrades
  • Testing only in Expo Go and misconfiguring runtime versions for expo-updates, so an OTA update ships JavaScript that crashes against mismatched native binaries
  • Burning EAS build minutes on every commit in CI without caching or local builds, turning a convenience service into a surprise line item
  • Skipping several SDK upgrades in a row, then being forced into a large breaking migration when app store target API level deadlines arrive

Frequently asked questions

Is Expo's push notification service reliable enough for production at scale?

Yes, for the large majority of apps. It relays through FCM and APNs, so delivery characteristics are close to direct integration. The failures teams attribute to Expo are usually operational: unprocessed receipts, dead tokens never pruned, or missing Android channel setup. Teams with extreme volume, strict latency needs, or compliance rules about third-party infrastructure are the ones with genuine reasons to integrate FCM and APNs directly.

What is DeviceNotRegistered in Expo push receipts and what should I do?

It's the platform telling you a push token is permanently invalid — typically because the user uninstalled the app or the token rotated. When a receipt returns DeviceNotRegistered, delete that token from your database immediately and stop sending to it. Continuing to send to dead tokens wastes volume and is the kind of behavior push platforms throttle, which then degrades delivery for your real users.

Why are my Expo push notifications silent on Android?

Almost always a notification channel problem. Android requires channels, and a channel's importance level — set at creation — controls sound and heads-up display; users can also mute channels themselves. Create channels explicitly with appropriate importance, send transactional and marketing messages on separate channels, and remember that once a channel exists you can't programmatically raise its importance, only the user can.

Should we use Expo or plain React Native for a new app?

The React Native docs themselves now recommend starting with a framework, and Expo is the primary one; 71% of surveyed developers already build with EAS. Expo today supports custom native code through development builds and config plugins, so the old limitations that forced teams to avoid it mostly no longer apply.

Does Expo lock us into their platform?

No; expo prebuild can generate standard native iOS and Android projects at any time, and the open-source tooling works without Expo's paid EAS services, as demonstrated by Doctolib running Expo without EAS on an app with 90 million users. EAS Build, Submit, and Updates are optional conveniences, not requirements.

Is Expo production-ready for a serious commercial app?

Yes; Expo Go is only a development sandbox, while production apps ship as normal store binaries built with EAS or locally. Plan for the SDK release cadence of roughly three versions per year, since staying current is required to keep up with store policy and React Native releases.

Bottom line: Dhairya Senjaliya ships Mobile — Expo Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.

Sources

Related guides

Keep up with new guides

New deep-dive guides on React Native, Python, and AI ship regularly. Subscribe via RSS or follow on LinkedIn.

Want help implementing this?

30-minute scoping call · Clear milestones · Senior engineer ownership