Before diving into scaling and optimization, let's understand the fundamental building blocks required to send your first push notification. These components form the foundation that everything else builds upon.

Device token registration

The journey begins when a user installs your app. Without a valid device token, you cannot send push notifications, as it's the address that tells Apple or Google where to deliver your message. Token management is more complex than it appears: tokens can change without warning, differ across app installations, and expire when apps are uninstalled.

// Mobile app: Request permission and register token
async function registerForPushNotifications() {
  // Request permission (iOS requires explicit permission, Android 13+)
  const { status } = await requestNotificationPermission();
 
  if (status !== "granted") {
    console.log("Permission denied");
    return;
  }
 
  // Get token from platform service
  const token = await getDeviceToken();
 
  // Register with your backend
  await api.post("/devices/register", {
    token,
    platform: Platform.OS, // 'ios' or 'android'
    appVersion: getAppVersion(),
    deviceInfo: {
      model: getDeviceModel(),
      osVersion: getOSVersion(),
    },
  });
}
 
// Backend: Store device tokens
app.post("/devices/register", async (req, res) => {
  const { token, platform, appVersion, deviceInfo } = req.body;
  const userId = req.user.id;
 
  try {
    await db.collection("devices").updateOne(
      { token }, // Use token as unique identifier
      {
        $set: {
          userId,
          platform,
          appVersion,
          deviceInfo,
          lastRegistered: new Date(),
          active: true,
        },
        $setOnInsert: {
          createdAt: new Date(),
        },
      },
      { upsert: true },
    );
 
    // Important: A user might have multiple devices
    await db
      .collection("users")
      .updateOne({ _id: userId }, { $addToSet: { deviceTokens: token } });
 
    res.json({ success: true });
  } catch (error) {
    console.error("Token registration failed:", error);
    res.status(500).json({ error: "Registration failed" });
  }
});

Token registration must handle edge cases gracefully. When a user logs out, mark their token as inactive, but don't delete it, as they might log back in. When the same token appears with a different user ID, transfer ownership carefully. If a user logs into your app from a second device, you’ll want to store a push token for that device as well. Apps should re-register tokens on every launch to catch token refreshes that happen silently in the background.

Provider authentication setup

Before sending notifications, you must authenticate with APNs and FCM. This one-time setup is critical, as incorrect configuration here means no notifications will ever be delivered. Each provider has different requirements and authentication methods.

Apple Push Notification Service (APNs)

APNs uses JWT-based authentication with a private key from your Apple Developer account. Unlike FCM, APNs requires you to generate and manage authentication tokens manually. The tokens are valid for 1 hour, so you need to implement token caching and refresh logic. APNs also has stricter payload requirements and different error codes than FCM.

// APNs authentication using JWT (recommended over certificates)
class APNSClient {
  constructor(config) {
    this.teamId = config.teamId;
    this.keyId = config.keyId;
    this.privateKey = config.privateKey;
    this.bundleId = config.bundleId;
    this.tokenCache = null;
    this.tokenExpiry = null;
  }
 
  generateToken() {
    // APNs tokens are valid for 1 hour
    if (this.tokenCache && this.tokenExpiry > Date.now()) {
      return this.tokenCache;
    }
 
    const claims = {
      iss: this.teamId,
      iat: Math.floor(Date.now() / 1000),
    };
 
    this.tokenCache = jwt.sign(claims, this.privateKey, {
      algorithm: "ES256",
      keyid: this.keyId,
    });
 
    this.tokenExpiry = Date.now() + 3500 * 1000; // Refresh before expiry
    return this.tokenCache;
  }
 
  async sendNotification(deviceToken, payload) {
    const token = this.generateToken();
 
    const response = await fetch(
      `https://api.push.apple.com/3/device/${deviceToken}`,
      {
        method: "POST",
        headers: {
          authorization: `bearer ${token}`,
          "apns-topic": this.bundleId,
          "apns-push-type": "alert",
          "apns-priority": "10",
        },
        body: JSON.stringify(payload),
      },
    );
 
    if (!response.ok) {
      const error = await response.text();
      throw new Error(`APNs error: ${response.status} - ${error}`);
    }
 
    return {
      success: true,
      apnsId: response.headers.get("apns-id"),
    };
  }
}

Firebase Cloud Messaging (FCM)

FCM uses OAuth2 authentication with a Google service account, which handles token generation and refresh automatically. This approach is simpler than APNs since Google's client libraries manage the authentication lifecycle for you. FCM also provides more flexible payload structures and better support for cross-platform messaging, but requires setting up a Firebase project and service account credentials.

// FCM authentication and setup
class FCMClient {
  constructor(config) {
    // FCM uses OAuth2 with service account
    this.auth = new GoogleAuth({
      credentials: config.serviceAccount,
      scopes: ["https://www.googleapis.com/auth/firebase.messaging"],
    });
    this.projectId = config.projectId;
  }
 
  async sendNotification(deviceToken, payload) {
    const accessToken = await this.auth.getAccessToken();
 
    const message = {
      message: {
        token: deviceToken,
        notification: payload.notification,
        data: payload.data,
        android: {
          priority: "high",
          ttl: "3600s",
        },
        apns: {
          headers: {
            "apns-priority": "10",
          },
          payload: {
            aps: {
              alert: payload.notification,
            },
          },
        },
      },
    };
 
    const response = await fetch(
      `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${accessToken}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(message),
      },
    );
 
    const result = await response.json();
    if (!response.ok) {
      throw new Error(`FCM error: ${result.error.message}`);
    }
 
    return { success: true, messageId: result.name };
  }
}

Store credentials securely using environment variables or secret management services. Never commit keys to version control. Set up separate credentials for development and production environments to avoid accidentally sending test notifications to real users.

Notification payload structure

The payload determines what users actually see and interact with, and the payload structure is determined by the Apple and FCM push services. But each platform has specific requirements and capabilities—what works on iOS might fail on Android. Understanding payload structure is crucial for creating engaging notifications that render correctly across all devices.

// Cross-platform notification builder
class NotificationBuilder {
  constructor() {
    this.payload = {};
  }
 
  setTitle(title) {
    this.payload.title = title;
    return this;
  }
 
  setBody(body) {
    this.payload.body = body;
    return this;
  }
 
  setImage(imageUrl) {
    this.payload.imageUrl = imageUrl;
    return this;
  }
 
  setData(data) {
    // Custom data for app to process
    this.payload.data = data;
    return this;
  }
 
  setBadge(count) {
    this.payload.badge = count;
    return this;
  }
 
  setSound(sound) {
    this.payload.sound = sound || "default";
    return this;
  }
 
  buildForAPNS() {
    const apnsPayload = {
      aps: {
        alert: {
          title: this.payload.title,
          body: this.payload.body,
        },
      },
    };
 
    if (this.payload.badge !== undefined) {
      apnsPayload.aps.badge = this.payload.badge;
    }
 
    if (this.payload.sound) {
      apnsPayload.aps.sound = this.payload.sound;
    }
 
    if (this.payload.imageUrl) {
      apnsPayload.aps["mutable-content"] = 1;
      apnsPayload.imageUrl = this.payload.imageUrl;
    }
 
    // Add custom data outside aps
    if (this.payload.data) {
      Object.assign(apnsPayload, this.payload.data);
    }
 
    return apnsPayload;
  }
 
  buildForFCM() {
    const fcmPayload = {
      notification: {
        title: this.payload.title,
        body: this.payload.body,
      },
    };
 
    if (this.payload.imageUrl) {
      fcmPayload.notification.image = this.payload.imageUrl;
    }
 
    if (this.payload.data) {
      fcmPayload.data = Object.keys(this.payload.data).reduce((acc, key) => {
        // FCM data must be strings
        acc[key] = String(this.payload.data[key]);
        return acc;
      }, {});
    }
 
    if (this.payload.badge !== undefined) {
      fcmPayload.notification.badge = String(this.payload.badge);
    }
 
    return fcmPayload;
  }
}
 
// Usage example
const notification = new NotificationBuilder()
  .setTitle("New Message")
  .setBody("You have a message from Sarah")
  .setBadge(1)
  .setData({
    conversationId: "12345",
    messageType: "text",
  })
  .setSound("message.wav");
 
const apnsPayload = notification.buildForAPNS();
const fcmPayload = notification.buildForFCM();

Example APNs Payload

{
  "aps": {
    "alert": {
      "title": "New Message",
      "body": "You have a message from Sarah"
    },
    "badge": 1,
    "sound": "default",
    "mutable-content": 1
  },
  "imageUrl": "https://example.com/avatar.jpg",
  "conversationId": "12345",
  "messageType": "text"
}

Example FCM Payload

{
  "notification": {
    "title": "New Message",
    "body": "You have a message from Sarah",
    "image": "https://example.com/avatar.jpg",
    "badge": "1"
  },
  "data": {
    "conversationId": "12345",
    "messageType": "text"
  }
}

Rich notifications with images, action buttons, or custom layouts require additional configuration. iOS needs notification service extensions for images, while Android handles them natively. Always test payloads on actual devices as simulators don't perfectly replicate notification behavior.

The sending pipeline

With tokens registered, providers authenticated, and payloads structured, you need a reliable pipeline to orchestrate delivery. This pipeline handles platform detection, error handling, and delivery tracking. A well-designed pipeline is the difference between notifications that usually work and a system you can trust.

class NotificationService {
  constructor(apnsClient, fcmClient, db) {
    this.apns = apnsClient;
    this.fcm = fcmClient;
    this.db = db;
  }
 
  async send(userId, notification) {
    // Get user's devices
    const devices = await this.db
      .collection("devices")
      .find({
        userId,
        active: true,
      })
      .toArray();
 
    if (devices.length === 0) {
      throw new Error("No active devices found");
    }
 
    const results = await Promise.allSettled(
      devices.map((device) => this.sendToDevice(device, notification)),
    );
 
    // Process results
    const summary = {
      total: devices.length,
      successful: 0,
      failed: 0,
      errors: [],
    };
 
    results.forEach((result, index) => {
      if (result.status === "fulfilled") {
        summary.successful++;
      } else {
        summary.failed++;
        summary.errors.push({
          device: devices[index].token,
          error: result.reason.message,
        });
      }
    });
 
    // Log delivery attempt
    await this.logDelivery(userId, notification, summary);
 
    return summary;
  }
 
  async sendToDevice(device, notification) {
    try {
      let result;
 
      if (device.platform === "ios") {
        const payload = notification.buildForAPNS();
        result = await this.apns.sendNotification(device.token, payload);
      } else if (device.platform === "android") {
        const payload = notification.buildForFCM();
        result = await this.fcm.sendNotification(device.token, payload);
      } else {
        throw new Error(`Unsupported platform: ${device.platform}`);
      }
 
      // Update last successful send
      await this.db
        .collection("devices")
        .updateOne(
          { token: device.token },
          { $set: { lastSuccessfulSend: new Date() } },
        );
 
      return result;
    } catch (error) {
      // Handle token-specific errors
      if (this.isInvalidTokenError(error)) {
        await this.handleInvalidToken(device.token, error);
      }
 
      throw error;
    }
  }
 
  isInvalidTokenError(error) {
    return (
      error.message.includes("Unregistered") ||
      error.message.includes("InvalidRegistration") ||
      error.message.includes("BadDeviceToken")
    );
  }
 
  async handleInvalidToken(token, error) {
    await this.db.collection("devices").updateOne(
      { token },
      {
        $set: {
          active: false,
          deactivatedAt: new Date(),
          deactivationReason: error.message,
        },
      },
    );
  }
}

Error handling deserves special attention here. Provider errors fall into 3 categories:

  1. Invalid tokens (remove them)
  2. Rate limits (retry with backoff)
  3. Temporary failures (retry immediately).

Each provider will have different error codes that map to these categories, so be sure to reference the Apple developer docs or FCM documentation depending on your chosen provider. Here are some commons ones:

APNs error codes

Error CodeDescriptionAction Required
BadDeviceToken

The specified device token is invalid or the device has been unregistered from APNs

Remove the token from your database and stop sending to it
BadTopicThe topic is invalid or not authorized for the specified device

Check your topic name format and ensure it matches your app's bundle ID

DeviceTokenNotForTopicThe device token does not match the specified topicVerify the topic matches the app's bundle ID
DuplicateHeadersOne or more headers were repeated in the requestRemove duplicate headers from your request
IdleTimeoutThe connection to APNs was idle for too longReconnect to APNs and retry the request
InvalidTokenSizeThe device token is not the correct sizeVerify the token format and length (should be 64 characters)
MissingDeviceTokenThe device token is missing from the requestInclude a valid device token in your request
MissingTopicThe topic is missing from the request (required for iOS 13+)Include the apns-topic header with your app's bundle ID
PayloadEmptyThe message payload is emptyInclude a valid payload in your request
PayloadTooLargeThe message payload exceeds the 4KB limitReduce the payload size to under 4KB
TopicDisallowedThe topic is not allowed for this deviceUse the correct topic (usually your app's bundle ID)
UnregisteredThe device token is no longer valid for the topicRemove the token and request a new one from the device

FCM error codes

Error CodeDescriptionAction Required
messaging/registration-token-not-registered

The provided registration token is not registered. This can happen when the app is uninstalled, the token expires, or the app is updated

Remove this registration token and stop using it to send messages
messaging/invalid-package-name

The message was addressed to a registration token whose package name does not match the provided restrictedPackageName option

Verify the package name matches your Android app's package name
messaging/message-rate-exceededThe rate of messages to a particular target is too high

Reduce the number of messages sent to this device or topic and implement exponential backoff

messaging/device-message-rate-exceededThe rate of messages to a particular device is too high

Reduce the number of messages sent to this device and implement exponential backoff

messaging/topics-message-rate-exceeded

The rate of messages to subscribers to a particular topic is too high

Reduce the number of messages sent for that topic and implement exponential backoff

messaging/too-many-topics

A registration token has been subscribed to the maximum number of topics and cannot be subscribed to any more

Unsubscribe the token from some topics before subscribing to new ones

messaging/invalid-apns-credentials

A message targeted to an Apple device couldn't be sent because the required APNs SSL certificate was not uploaded or has expired

Check the validity of your development and production certificates

messaging/mismatched-credential

The credential used to authenticate this SDK does not have permission to send messages to the device corresponding to the provided registration token

Make sure the credential and registration token both belong to the same Firebase project

messaging/authentication-errorThe SDK couldn't authenticate to the FCM servers

Make sure you authenticate the Firebase Admin SDK with a credential which has the proper permissions

messaging/server-unavailableThe FCM server couldn't process the request in time

Retry the same request with exponential backoff, honoring the Retry-After header if present

messaging/internal-error

The FCM server encountered an error while trying to process the request

Retry the same request following exponential backoff requirements. If the error persists, report to Firebase support

messaging/unknown-errorAn unknown server error was returnedReport the full error message to Firebase support

If you’re seeing consistently failing devices, make sure you track success rates per device. Users might have outdated app versions or maybe even uninstalled your app.

These core components—token management, provider authentication, payload structure, and the sending pipeline—form the essential foundation of any push notification system. Get these right, and you can reliably deliver notifications to your users. Only after mastering these basics should you worry about scaling to millions of users or implementing complex features.