Published on

Push Notifications at Scale — Web Push, APNs, FCM, and the Delivery Problem

Authors

Introduction

Notifications drive engagement. But delivery at scale is complex. Web Push, FCM, and APNs all have different APIs. Devices go offline, tokens expire, and delivery fails silently. Build a notification service that handles routing, retries, and persistence.

This post covers the full stack: from user subscription through verified delivery.

Web Push API (VAPID)

Modern browsers support Web Push. Users opt-in, and you send notifications from the server.

First, generate VAPID keys (do this once):

npm install web-push -g
web-push generate-vapid-keys

Store the keys securely. On the client, request permission and subscribe:

async function subscribeToNotifications() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(
      process.env.REACT_APP_VAPID_PUBLIC_KEY
    )
  });

  // Send subscription to server
  await fetch('/api/notifications/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

function urlBase64ToUint8Array(base64String: string) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return new Uint8Array([...rawData].map((char) => char.charCodeAt(0)));
}

On the server, save the subscription and send notifications:

import webpush from 'web-push';

webpush.setVapidDetails(
  process.env.VAPID_SUBJECT,
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

app.post('/api/notifications/subscribe', express.json(), async (req, res) => {
  const { userId } = req.session;
  const subscription = req.body;

  // Save to database
  await db.collection('subscriptions').insertOne({
    userId,
    subscription,
    createdAt: new Date()
  });

  res.json({ success: true });
});

async function sendWebPushNotification(userId: string, payload: any) {
  const subs = await db
    .collection('subscriptions')
    .find({ userId })
    .toArray();

  for (const sub of subs) {
    try {
      await webpush.sendNotification(
        sub.subscription,
        JSON.stringify(payload)
      );
    } catch (error: any) {
      if (error.statusCode === 410) {
        // Subscription expired, remove it
        await db
          .collection('subscriptions')
          .deleteOne({ _id: sub._id });
      }
    }
  }
}

Handle notifications in the service worker:

self.addEventListener('push', (event) => {
  const data = event.data?.json();
  const options = {
    body: data.body,
    icon: data.icon,
    badge: data.badge,
    tag: data.tag, // Group similar notifications
    data: data.data
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((clientList) => {
      for (const client of clientList) {
        if (client.url === event.notification.data.url) {
          return client.focus();
        }
      }
      return clients.openWindow(event.notification.data.url);
    })
  );
});

FCM for Android

Firebase Cloud Messaging (FCM) handles Android and web push. Set up Firebase:

npm install firebase-admin

Initialize:

import admin from 'firebase-admin';

admin.initializeApp({
  credential: admin.credential.cert(
    require('./firebase-key.json')
  ),
  projectId: process.env.FIREBASE_PROJECT_ID
});

On the Android client, register for push and send the token to your server:

// In your Android app
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
  if (task.isSuccessful) {
    val token = task.result
    // Send token to your server
    POST("/api/notifications/register-fcm", { token })
  }
}

On the server, save the token and send notifications:

async function registerFCMToken(userId: string, token: string) {
  await db.collection('fcm-tokens').updateOne(
    { userId },
    {
      $addToSet: { tokens: token },
      updatedAt: new Date()
    },
    { upsert: true }
  );
}

async function sendFCMNotification(userId: string, payload: any) {
  const doc = await db.collection('fcm-tokens').findOne({ userId });
  if (!doc) return;

  const message = {
    notification: {
      title: payload.title,
      body: payload.body
    },
    android: {
      ttl: 86400,
      priority: 'high'
    },
    data: payload.data
  };

  const results = await admin.messaging().sendAll(
    doc.tokens.map((token) => ({
      ...message,
      token
    }))
  );

  // Handle failures
  for (let i = 0; i < results.responses.length; i++) {
    const response = results.responses[i];
    if (!response.success) {
      if (
        response.error?.code ===
        'messaging/registration-token-not-registered'
      ) {
        // Remove expired token
        await db.collection('fcm-tokens').updateOne(
          { userId },
          { $pull: { tokens: doc.tokens[i] } }
        );
      }
    }
  }
}

APNs for iOS

Apple Push Notification service (APNs) requires a certificate or token. Get one from Apple Developer.

Using node-apn:

import apn from 'apn';

const apnProvider = new apn.Provider({
  token: {
    key: fs.readFileSync('./AuthKey_XXXX.p8'),
    keyId: 'YOUR_KEY_ID',
    teamId: 'YOUR_TEAM_ID'
  }
});

async function registerAPNToken(userId: string, token: string) {
  await db.collection('apn-tokens').updateOne(
    { userId },
    {
      $addToSet: { tokens: token },
      updatedAt: new Date()
    },
    { upsert: true }
  );
}

async function sendAPNNotification(userId: string, payload: any) {
  const doc = await db.collection('apn-tokens').findOne({ userId });
  if (!doc) return;

  const notification = new apn.Notification({
    alert: {
      title: payload.title,
      body: payload.body
    },
    badge: payload.badge || 1,
    sound: 'default',
    payload: payload.data,
    topic: 'com.yourcompany.yourapp'
  });

  const result = await apnProvider.send(notification, doc.tokens);

  // Remove failed tokens
  for (const token of result.failed) {
    await db.collection('apn-tokens').updateOne(
      { userId },
      { $pull: { tokens: token.device } }
    );
  }
}

Unified Service With web-push + Firebase Admin SDK

Wrap all three in a unified interface:

type NotificationChannel = 'web' | 'fcm' | 'apn';

interface NotificationPayload {
  title: string;
  body: string;
  icon?: string;
  data?: Record<string, string>;
  badge?: number;
}

class NotificationService {
  async send(
    userId: string,
    payload: NotificationPayload,
    channels: NotificationChannel[] = ['web', 'fcm', 'apn']
  ) {
    const sendFunctions: Record<NotificationChannel, () => Promise<void>> = {
      web: () => sendWebPushNotification(userId, payload),
      fcm: () => sendFCMNotification(userId, payload),
      apn: () => sendAPNNotification(userId, payload)
    };

    const results = await Promise.allSettled(
      channels.map((channel) => sendFunctions[channel]())
    );

    return {
      success: results.filter((r) => r.status === 'fulfilled').length,
      failed: results.filter((r) => r.status === 'rejected').length
    };
  }
}

const notificationService = new NotificationService();

// Use it
await notificationService.send('user-123', {
  title: 'New Message',
  body: 'You have a new message from Alice'
});

Notification Routing by Device Type

Users have multiple devices. Send to all or let them choose:

async function getUserDevices(userId: string) {
  const [webSubs, fcmTokens, apnTokens] = await Promise.all([
    db
      .collection('subscriptions')
      .find({ userId })
      .project({ _id: 0, subscription: 1 })
      .toArray(),
    db
      .collection('fcm-tokens')
      .findOne({ userId }, { projection: { tokens: 1 } }),
    db.collection('apn-tokens')
      .findOne({ userId }, { projection: { tokens: 1 } })
  ]);

  return {
    web: webSubs.length,
    android: fcmTokens?.tokens.length || 0,
    ios: apnTokens?.tokens.length || 0
  };
}

// Send only to enabled channels
const devices = await getUserDevices(userId);
const channels: NotificationChannel[] = [];
if (devices.web &gt; 0) channels.push('web');
if (devices.android &gt; 0) channels.push('fcm');
if (devices.ios &gt; 0) channels.push('apn');

await notificationService.send(userId, payload, channels);

Delivery Receipts and Read Tracking

Track whether notifications were delivered and read:

async function sendTrackableNotification(
  userId: string,
  payload: NotificationPayload
) {
  const notificationId = crypto.randomUUID();

  // Save in database
  await db.collection('notifications').insertOne({
    _id: notificationId,
    userId,
    payload,
    sent: new Date(),
    delivered: null,
    opened: null,
    status: 'sent'
  });

  // Include tracking pixel/beacon
  const notificationWithTracking = {
    ...payload,
    data: {
      ...payload.data,
      notificationId,
      trackingUrl: `https://yoursite.com/api/notifications/${notificationId}/delivered`
    }
  };

  await notificationService.send(userId, notificationWithTracking);

  return notificationId;
}

// Client opens notification
app.post('/api/notifications/:id/opened', async (req, res) => {
  const notificationId = req.params.id;
  await db.collection('notifications').updateOne(
    { _id: notificationId },
    {
      $set: {
        opened: new Date(),
        status: 'opened'
      }
    }
  );
  res.json({ success: true });
});

// Track delivery via beacon
app.post('/api/notifications/:id/delivered', async (req, res) => {
  const notificationId = req.params.id;
  await db.collection('notifications').updateOne(
    { _id: notificationId },
    {
      $set: {
        delivered: new Date(),
        status: 'delivered'
      }
    }
  );
  res.sendStatus(204);
});

Notification Deduplication

Users receive duplicate notifications from network retries. Deduplicate by ID:

async function sendWithDeduplication(
  userId: string,
  payload: NotificationPayload,
  deduplicationId?: string
) {
  const id = deduplicationId || crypto.randomUUID();

  // Check if already sent
  const existing = await db.collection('notifications').findOne({
    userId,
    deduplicationId: id
  });

  if (existing && existing.sent > Date.now() - 300000) {
    // Sent within 5 minutes, skip
    return existing._id;
  }

  const notificationId = await sendTrackableNotification(userId, payload);

  await db.collection('notifications').updateOne(
    { _id: notificationId },
    {
      $set: {
        deduplicationId: id
      }
    }
  );

  return notificationId;
}

Scheduled Notifications With BullMQ

Send notifications at a specific time:

import { Queue } from 'bullmq';

const notificationQueue = new Queue('notifications', {
  connection: redis
});

// Schedule a notification
await notificationQueue.add(
  'send',
  { userId: 'user-123', payload },
  {
    delay: 3600000, // 1 hour from now
    removeOnComplete: true,
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 }
  }
);

// Worker processes the queue
notificationQueue.process(async (job) => {
  const { userId, payload } = job.data;
  await notificationService.send(userId, payload);
});

Topic Subscriptions vs Direct Tokens

Instead of managing individual device tokens, use topic subscriptions (FCM terminology; APNs calls them tags).

A user subscribes to a topic:

// Android
FirebaseMessaging.getInstance().subscribeToTopic('sports')

// Server sends to topic
await admin.messaging().sendToTopic('sports', {
  notification: {
    title: 'Goal!',
    body: 'Your team scored'
  }
});

Topics simplify broadcasts. Send to millions of users in one call.

Managing Device Token Expiry

Device tokens expire. Remove stale tokens periodically:

async function cleanupExpiredTokens() {
  // APNs tokens
  const inactiveApns = await db.collection('apn-tokens').deleteMany({
    updatedAt: { $lt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) } // 90 days
  });

  // FCM tokens
  const inactiveFcm = await db.collection('fcm-tokens').deleteMany({
    updatedAt: { $lt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) }
  });

  console.log(`Removed ${inactiveApns.deletedCount} APNs tokens`);
  console.log(`Removed ${inactiveFcm.deletedCount} FCM tokens`);
}

// Run daily
schedule.scheduleJob('0 0 * * *', cleanupExpiredTokens);

Notification Analytics (Open Rate, Click Rate)

Track engagement:

async function getNotificationAnalytics(campaignId: string) {
  const notifications = await db
    .collection('notifications')
    .find({ campaignId })
    .toArray();

  const sent = notifications.length;
  const delivered = notifications.filter((n) => n.delivered).length;
  const opened = notifications.filter((n) => n.opened).length;

  return {
    sent,
    deliveryRate: (delivered / sent) * 100,
    openRate: (opened / sent) * 100,
    clickRate: notifications.filter((n) => n.clicked).length
  };
}

Monitor open rates to optimize send times and messaging.

Batching Sends (FCM Batch API)

FCM's batch API sends 1000 messages per request. Use it:

async function sendBatchFCM(messages: any[]) {
  const batchSize = 1000;
  for (let i = 0; i &lt; messages.length; i += batchSize) {
    const batch = messages.slice(i, i + batchSize);
    const response = await admin.messaging().sendAll(batch);
    console.log(`Sent batch: ${response.successCount} successful`);
  }
}

Reduces API calls and latency.

Users must opt-in. Track consent:

async function requestPushConsent(userId: string) {
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    await fetch('/api/notifications/consent', {
      method: 'POST',
      body: JSON.stringify({ userId, consent: true })
    });
  }
}

app.post('/api/notifications/consent', express.json(), async (req, res) => {
  const { userId, consent } = req.body;
  await db.collection('users').updateOne(
    { _id: userId },
    { $set: { pushConsent: consent } }
  );
  res.json({ success: true });
});

// Check before sending
const user = await db.collection('users').findOne({ _id: userId });
if (!user.pushConsent) {
  return; // Skip sending
}

Checklist

  • Generate VAPID keys and secure them
  • Implement Web Push subscription on client and server
  • Set up Firebase project for FCM
  • Configure APNs certificate and provisioning
  • Build unified notification service
  • Implement device token registration and cleanup
  • Add notification tracking (delivered, opened)
  • Set up deduplication logic
  • Add scheduled notifications with BullMQ
  • Monitor delivery rates and engagement
  • Implement GDPR consent tracking

Conclusion

Push notifications drive engagement but require careful handling of tokens, retries, and device management. Use a unified service that abstracts Web Push, FCM, and APNs. Add persistence for tracking and retries.

Start with Web Push for desktop users. Expand to FCM (Android) and APNs (iOS) as your user base grows. Use managed services like Braze or Iterable if scaling notifications becomes a burden.

Notifications that reach users matter more than volume sent. Focus on delivery reliability first.