Push Notifications: FCM, APNs, and What Happens in Between


Push notifications look simple from the surface: server sends a notification, user’s phone shows it. But the infrastructure between “server sends” and “user sees” involves two separate platforms (Apple and Google), platform-specific protocols, token management, and several categories of failure that don’t always surface as errors.

Understanding the pipeline makes the failure modes predictable.

The Architecture

Your server never sends a notification directly to a device. It sends a request to a notification gateway - either Firebase Cloud Messaging (FCM) for Android, or Apple Push Notification service (APNs) for iOS. The gateway maintains a persistent connection with the device and delivers the notification.

Your Server -> FCM/APNs Gateway -> Device

This indirection exists because maintaining persistent connections to hundreds of millions of mobile devices is a hard infrastructure problem. Apple and Google solve it once; apps don’t have to solve it themselves.

The gateway needs to know which device to send to. This is the device token (APNs) or registration token (FCM) - a unique identifier for the combination of app + device that the platform generates and rotates over time.

Your app gets a token from the platform SDK and sends it to your server. Your server stores it. When you want to send a notification, you include the token in your request to FCM/APNs.

FCM (Firebase Cloud Messaging)

FCM is Google’s notification infrastructure, and it handles both Android notifications and Chrome web notifications. It replaced the older GCM (Google Cloud Messaging).

A basic FCM send via the HTTP v1 API:

const { GoogleAuth } = require('google-auth-library');
const fetch = require('node-fetch');

async function sendPushNotification(deviceToken, title, body) {
    const auth = new GoogleAuth({
        scopes: ['https://www.googleapis.com/auth/firebase.messaging']
    });
    const accessToken = await auth.getAccessToken();

    const response = await fetch(
        `https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send`,
        {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${accessToken}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                message: {
                    token: deviceToken,
                    notification: {
                        title: title,
                        body: body
                    },
                    android: {
                        priority: 'high'
                    }
                }
            })
        }
    );

    return response.json();
}

FCM tokens have a lifecycle. They can become invalid when:

  • The user uninstalls the app
  • The user clears app data
  • FCM updates the token (happens periodically)
  • The user hasn’t opened the app in more than 270 days

When you send to an invalid token, FCM returns a specific error (UNREGISTERED or INVALID_ARGUMENT). These tokens should be deleted from your database immediately to avoid wasting requests.

APNs (Apple Push Notification service)

APNs is more strict than FCM. Apple requires a valid token signed with your app’s certificate or authentication key. There are two authentication methods:

Token-based (JWT): recommended. You have a private key from Apple Developer, and you create a signed JWT with it for each request. More secure, no expiration, same key works across apps.

Certificate-based: a certificate associated with your app ID. Expires after a year, must be renewed.

APNs has two environments: sandbox (development/TestFlight) and production (App Store). Sending a production token to the sandbox endpoint (or vice versa) fails silently - the notification isn’t delivered and you might not get a clear error.

A Python example using the apns2 library:

from apns2.client import APNsClient
from apns2.payload import Payload, PayloadAlert

def send_apns_notification(device_token, title, body):
    client = APNsClient(
        credentials='AuthKey_XXXXXXXXXX.p8',
        key_id='XXXXXXXXXX',
        team_id='XXXXXXXXXX',
        use_sandbox=False
    )

    payload = Payload(
        alert=PayloadAlert(title=title, body=body),
        sound='default',
        badge=1
    )

    result = client.send_notification(device_token, payload, topic='com.example.app')
    return result

APNs token management is similar to FCM. Invalid tokens return a BadDeviceToken or Unregistered reason. Remove them.

Token Management

Token management is where most push notification implementations have bugs. The failure mode is silent: you think you’re sending to active users, but you’re actually sending to a pile of stale tokens, getting errors, and not handling them.

Store tokens with metadata: when did you receive this token? What platform? What app version? When was the last successful send to it?

Handle errors immediately: when FCM or APNs tells you a token is invalid, delete it. Don’t retry. Don’t log and ignore.

Handle token rotation: apps should refresh and resend the token to your server periodically. FCM’s onTokenRefresh callback handles this on Android. On iOS, request the APNs token at app launch and compare with the stored one.

One user, many tokens: a user might have the app on their iPhone and iPad, or switch phones. Store all active tokens per user, not just the latest.

class PushToken(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    platform = db.Column(db.String(10))  # 'ios' or 'android'
    token = db.Column(db.String(256), unique=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    last_used_at = db.Column(db.DateTime)
    is_valid = db.Column(db.Boolean, default=True)

Notification Types

Alert notifications: show a banner with title and body. Require user permission (iOS: explicit prompt; Android 13+: explicit prompt).

Silent/background notifications: delivered to the app without showing anything visible to the user. The app processes them in the background (sync data, update badge count). Limited rate on iOS; can be deprioritized if the device is in low-power mode.

Data messages (FCM): equivalent to silent notifications on Android. The app receives a data payload and handles it entirely.

The appropriate type depends on what you’re doing. A chat message should be an alert notification - the user should see it. Syncing background data or updating a counter should be a silent notification.

Delivery Guarantees

Push notifications are best-effort. Neither FCM nor APNs guarantees delivery.

A notification might not be delivered because:

  • The device is offline (it’s held for a configurable TTL, then dropped)
  • The device is in battery optimization mode (Android can suppress background wakeups)
  • The user has disabled notifications for your app
  • The device’s notification quota for the app is exceeded

For critical notifications (account security alerts, transactional receipts), also deliver via email or SMS. Don’t design a system where a push notification is the only delivery mechanism for something important.

For the notification you want delivered even if the device is offline, set an appropriate TTL. FCM defaults to 4 weeks. For time-sensitive notifications (like “your ride is arriving”), set a short TTL (minutes) so stale notifications don’t arrive hours later and confuse users.



Read more