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:
- Invalid tokens (remove them)
- Rate limits (retry with backoff)
- 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 Code | Description | Action 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 |
BadTopic | The topic is invalid or not authorized for the specified device | Check your topic name format and ensure it matches your app's bundle ID |
DeviceTokenNotForTopic | The device token does not match the specified topic | Verify the topic matches the app's bundle ID |
DuplicateHeaders | One or more headers were repeated in the request | Remove duplicate headers from your request |
IdleTimeout | The connection to APNs was idle for too long | Reconnect to APNs and retry the request |
InvalidTokenSize | The device token is not the correct size | Verify the token format and length (should be 64 characters) |
MissingDeviceToken | The device token is missing from the request | Include a valid device token in your request |
MissingTopic | The topic is missing from the request (required for iOS 13+) | Include the apns-topic header with your app's bundle ID |
PayloadEmpty | The message payload is empty | Include a valid payload in your request |
PayloadTooLarge | The message payload exceeds the 4KB limit | Reduce the payload size to under 4KB |
TopicDisallowed | The topic is not allowed for this device | Use the correct topic (usually your app's bundle ID) |
Unregistered | The device token is no longer valid for the topic | Remove the token and request a new one from the device |
FCM error codes
Error Code | Description | Action 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-exceeded | The 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-exceeded | The 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-error | The 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-unavailable | The 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-error | An unknown server error was returned | Report 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.