← Back to the library
MobileApplied · 8 min

Push notifications never arrive for some users

“The send API returns success for everyone, but a slice of users never sees the notification: a few platforms, a few builds, or people who reinstalled.”

ERROR CODES YOU MAY SEE

A code is a clue. Use its meaning and surrounding evidence to narrow the cause.

UNREGISTEREDFirebase Cloud Messaging HTTP v1 · platform

HTTP 404: the app instance was unregistered from FCM, so the registration token is no longer valid.

Delete the token on this response. A rising rate after a release suggests the app stopped uploading refreshed tokens; one user with many dead tokens suggests reinstalls.

Official reference for UNREGISTERED (opens in new tab)Code definition reviewed
SENDER_ID_MISMATCHFirebase Cloud Messaging HTTP v1 · platform

HTTP 403: the authenticated sender ID differs from the sender ID the registration token was issued for.

The token came from a different Firebase project, typically a staging build's google-services.json or GoogleService-Info.plist talking to the production backend.

Official reference for SENDER_ID_MISMATCH (opens in new tab)Code definition reviewed
410APNs HTTP/2 responses · platform

The device token is no longer active for the topic (reason Unregistered or ExpiredToken); the response carries a timestamp of when APNs confirmed it.

Stop sending to the token unless the device registers it again later than that timestamp. A 400 BadDeviceToken instead usually means a sandbox token sent to production or the reverse.

Official reference for 410 (opens in new tab)Code definition reviewed

THE PRINCIPLE

A successful send means the provider accepted the message, not that a person saw it. Treat tokens as expiring, per-environment credentials and treat silent pushes as best effort.

FIRST MOVES

  1. Log provider responses per token. On FCM UNREGISTERED (404) or APNs 410, delete the token; on APNs 400 BadDeviceToken, check you are not sending a sandbox (Xcode) token to api.push.apple.com or a TestFlight/App Store token to the sandbox host.
  2. Upload the token on every app start and on the refresh callback, keyed by install, with a last-seen timestamp. FCM treats registrations idle for a month as stale and expires Android ones after 270 days; prune your own stale rows.
  3. Check permission before blaming delivery: since Android 13 POST_NOTIFICATIONS is off by default for new installs, iOS needs requestAuthorization, and on Android a channel the user turned off, or a channel ID that was never created, shows nothing. A channel created with low importance stays low: the app can't raise it later, so ship a new channel ID instead.
  4. Do not rely on silent pushes for anything that must happen. iOS treats content-available pushes as low priority, throttles them (Apple says no more than two or three per hour), and drops them for apps the user force-quit.
  5. On Android, high-priority FCM messages that never produce a visible notification get deprioritized; send user-facing alerts as notification messages and keep data-only messages for sync (the AndroidConfigV2 preview makes this explicit with RemoteNotification and BackgroundSync types).

TOOLS: THEN → NOW

Legacy FCM server keys and per-platform payloadsFCM HTTP v1 with OAuth service accounts, or Expo Push Service receipts for Expo apps
Guessing from device logsApple Push Notifications Console delivery logs, Firebase Messaging reports, adb shell dumpsys notification
Silent push as a sync triggerVisible notifications for urgent events, background tasks plus sync-on-open for data

PATTERN SNAPSHOT

// Server: prune tokens from provider feedback, never on a guess.
async function handleSendResult(token: DeviceToken, res: ProviderResult) {
  if (res.provider === "fcm" && res.errorCode === "UNREGISTERED") return tokens.delete(token.id);
  if (res.provider === "apns" && res.status === 410) {
    // Only delete if the device has not re-registered since APNs invalidated it.
    if (token.lastSeenAt.getTime() < res.timestamp) return tokens.delete(token.id);
  }
  if (res.provider === "apns" && res.reason === "BadDeviceToken") {
    metrics.increment("apns.env_mismatch", { build: token.buildChannel });
  }
}

CLOSE THE AI. EXPLAIN THIS.

Why can a push sent to a TestFlight user fail with BadDeviceToken while the same code works from Xcode?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed