Push Notifications: Architecture and Reliability
A production engineering guide to mobile push notification architecture, covering APNs and FCM delivery models, token lifecycle management, silent pushes, and secure dispatch.

Push Notifications: Architecture and Reliability
Push notifications are often introduced into an application architecture as if they were synchronous RPCs or reliable message queues. An engineer triggers a dispatch call on the backend, observes a banner appear on a test phone across the desk, and assumes the delivery pipeline is complete.
In a scaled production environment, that mental model breaks down quickly. Push notifications are not point-to-point network calls. They traverse a distributed, multi-hop pipeline across proprietary platform gateways, cellular radio towers, operating system power-management daemons, and variable device battery states.
Building a resilient mobile system requires treating push notifications according to their true system dynamics: best-effort, at-least-once, out-of-order delivery mechanisms. When push notifications are treated as transactional data buses, applications suffer from state drift, missed events, phantom badge counts, and critical battery drain. When they are architected as lightweight wake-up nudges or cache invalidation signals, the resulting systems remain dependable even under severe network and power constraints.
This guide analyzes the architectural requirements for production-grade push notification systems across iOS, Android, and Web platforms. It covers gateway communication protocols, device token lifecycle state machines, silent background execution budgets, in-flight payload decryption, and high-throughput dispatch engines.
1. The Distributed Tripartite Delivery Model
Every push notification travels through a tripartite architecture consisting of three decoupled entities: the application backend, the platform push gateway, and the target mobile device.
+-----------------------------------------------------------------------------------+
| PUSH NOTIFICATION LIFECYCLE |
+-----------------------------------------------------------------------------------+
[ Client App ]
│
│ 1. Acquire Device Token
│ (iOS: didRegisterForRemoteNotificationsWithDeviceToken)
│ (Android: FirebaseMessaging.getInstance().getToken())
▼
[ Mobile OS Gateway (APNs Client / Google Play Services) ]
│
│ 2. Return Push Token String
▼
[ Client App ]
│
│ 3. Register Token with Backend (Atomic Upsert)
▼
[ Application Backend / Token Registry (PostgreSQL) ]
│
│ 4. Business Event Ingestion (e.g., Order Shipped, Chat Message)
▼
[ Event Ingestion Bus (Apache Kafka / RabbitMQ) ]
│
│ 5. Partitioned Event Stream
▼
[ Push Dispatch Workers (Sharded by Provider) ]
│
├───────────────────────────────┬───────────────────────────────┐
│ (HTTP/2 Connection Pool) │ (HTTP v1 REST API) │
▼ ▼ ▼
[ Apple Push Notification service ] [ Firebase Cloud Messaging ] [ Web Push Service ]
│ (APNs Edge) │ (FCM Edge) │ (VAPID / RFC 8030)
│ │ │
│ 6. Best-Effort Delivery │ 6. Best-Effort Delivery │ 6. Best-Effort Delivery
▼ ▼ ▼
[ iOS Device (APNs Daemon) ] [ Android Device (Play Services) ] [ Browser Push Agent ]
│ │ │
│ 7. Power State Filtering │ 7. Doze / App Standby Check │ 7. User Engagement Check
▼ ▼ ▼
[ Banner Display / App Process ] [ Banner Display / App Process ] [ Desktop Banner ]
The Delivery Guarantees Reality Check
When designing backend push services, engineers must internalize three non-negotiable operational invariants:
- Best-Effort Delivery: Neither Apple Push Notification service (APNs) nor Firebase Cloud Messaging (FCM) guarantees delivery. If a device is turned off, disconnected from cellular networks, or experiencing radio interference beyond the gateway's time-to-live (TTL) window, the message is discarded without notice.
- At-Least-Once Delivery: Network retries between platform gateways and cellular modems can cause identical payloads to be delivered multiple times. Client-side handling logic must always be idempotent.
- Out-of-Order Arrival: If an event stream issues notification followed by notification , variable gateway queue latencies and device reconnect timings mean the device operating system may receive and render notification before notification .
The Operating System Power Gatekeeper
Even after a platform gateway successfully routes a packet to a physical phone, the mobile operating system acts as an aggressive gatekeeper to conserve battery life:
- iOS Power Management: Under iOS Low Power Mode, or when a user disables Background App Refresh in system settings, Apple disables or heavily throttles background execution. The operating system may drop silent notifications entirely or defer processing until the device is connected to AC power.
- Android Doze Mode & App Standby: Introduced in Android 6.0 (API 23) and tightened in subsequent releases, Doze mode restricts background network access and CPU wakelocks when the device is unplugged, stationary, and screen-off. Normal-priority FCM messages are deferred until periodic "maintenance windows," while high-priority messages are metered by a platform quota that drops down if an app wakes the radio excessively without user engagement.
- App Standby Buckets: Android categorizes apps into tiers (Active, Working Set, Frequent, Rare, Restricted) based on recent user behavior. Apps in the Rare or Restricted buckets face strict caps on daily high-priority pushes and background execution jobs.
[!IMPORTANT] The Cardinal Rule of Push Architecture: Push notifications are wake-up nudges or cache invalidation signals, NOT reliable transactional message buses. Never rely on a push notification as the sole mechanism to deliver financial transactions, account credentials, or state transitions. The source of truth must reside in your backend database, fetched over authenticated TLS when the application activates.
2. Platform Push Gateways: Protocols and Constraints
Each mobile platform operates its own proprietary gateway infrastructure with distinct transport protocols, authentication schemes, payload structures, and concurrency requirements.
Apple Push Notification service (APNs)
APNs operates exclusively over HTTP/2 with TLS 1.2+. Backend servers communicate with APNs using either JSON Web Tokens (JWT with ES256 algorithm) or mutual TLS (mTLS) client certificates. JWT authentication is standard practice because a single signing key can dispatch to multiple apps under the same Apple Developer team and does not require annual certificate rotations.
Key APNs HTTP/2 headers include:
apns-topic: The target bundle identifier (e.g.,com.example.app). For VoIP pushes, append.voip; for watch complications, append.complication.apns-push-type: Explicitly required since iOS 13. Valid values includealert,background,voip,complication,fileprovider,mdm, andliveactivity. Mismatched push types result in gateway rejection or dropped delivery.apns-priority:10(deliver immediately, wakes device radio; required foralert) or5(deliver at a power-conserving time; required forbackground).apns-collapse-id: An arbitrary string up to 64 bytes. When multiple notifications share the same collapse identifier, APNs overwrites pending undelivered notifications on the device with the newest entry.apns-expiration: A UNIX epoch timestamp in seconds. A value of0directs APNs to attempt delivery once and immediately discard the message if the device cannot be reached.
Firebase Cloud Messaging (FCM HTTP v1)
Google deprecated the legacy FCM HTTP and XMPP protocols in favor of the FCM HTTP v1 REST API. Authentication relies on short-lived OAuth 2.0 access tokens generated from Google Cloud Service Account credentials with the https://www.googleapis.com/auth/firebase.messaging scope.
Key characteristics of FCM HTTP v1:
- Targeting Flexibility: Pushes can be routed to a specific device
token, a published pub/subtopic, or a booleanconditioncombining multiple topics. - Platform Overrides: The payload schema allows granular overrides across
android,webpush, andapnswithin a single dispatch request. - Android Priority: Can be set to
HIGH(wakes device from Doze, invokes background service within platform quota) orNORMAL(delivered during regular Doze maintenance windows).
Comparison: Gateway Architectures Across Platforms
The following matrix compares platform push implementations across key operational dimensions:
| Dimension | Apple Push Notification service (APNs) | Firebase Cloud Messaging (FCM HTTP v1) | Web Push (W3C / RFC 8030) |
| :--- | :--- | :--- | :--- |
| Transport Protocol | HTTP/2 over TLS | HTTP/1.1 or HTTP/2 REST | HTTP/2 or HTTP/1.1 REST |
| Authentication | Provider JWT (ES256) or mTLS | OAuth 2.0 Bearer Token (GCP Service Account) | VAPID JWT (RFC 8292) over ECDSA P-256 |
| Max Payload Size | 4096 bytes (4 KB) for alert/background; 5120 bytes for VoIP | 4096 bytes (4 KB) for data messages | 4096 bytes (RFC 8291 payload encryption) |
| Invalidation Signal | HTTP/2 status 410 Unregistered or 400 BadDeviceToken | HTTP status 404 or JSON code UNREGISTERED | HTTP status 404 or 410 Gone |
| Coalescing Header | apns-collapse-id (64 bytes max) | android.collapse_key / apns-collapse-id | Topic header (RFC 8030) |
| In-Flight Mutation | Supported (UNNotificationServiceExtension) | Supported via custom Android BroadcastReceiver | Supported via Service Worker push event |
| Runtime Permissions | Explicit prompt (UNUserNotificationCenter) | Explicit prompt (Android 13+ POST_NOTIFICATIONS) | Explicit prompt (Notification.requestPermission()) |
3. The Device Token Lifecycle & Registration State Machine
A push token is not a static hardware identifier. It is an ephemeral, cryptographically generated capability string issued by the mobile operating system that authorizes a specific backend application to route data to a specific app installation.
Why Tokens Rotate
Push tokens can and will rotate across normal device lifecycles. A resilient backend must expect token turnover driven by:
- App Uninstallation and Reinstallation: Deleting an application invalidates its existing token. Reinstalling the app issues a completely new token string.
- Device Backup Restorations: Restoring an iCloud or Google backup onto a new physical phone does not transfer valid push tokens; the newly restored instance generates a new token on initial launch.
- Operating System Upgrades: Major OS upgrades can trigger internal key renegotiations between the platform daemon and gateway servers.
- Platform Provider Certificate / Key Rotations: Rotating push credentials on the backend can invalidate existing client registrations.
Handling Invalidation Signals and Tombstoning
When a user uninstalls your application, the platform gateway initially has no awareness of the event. When your dispatch worker subsequently attempts to deliver a push, the gateway attempts delivery to the device's last known radio route, fails, and returns a terminal invalidation signal:
- APNs: Returns HTTP/2 status
410 Unregistered(or400 BadDeviceTokenif malformed). - FCM: Returns an error payload containing
UNREGISTEREDorNOT_FOUND.
[ Dispatch Worker ] ──── Send Push (Token: 0x9f4a...) ────► [ Gateway (APNs/FCM) ]
│
│ Device uninstalled
▼
[ Dispatch Worker ] ◄─── HTTP 410 Unregistered / UNREGISTERED ────┘
│
│ Trigger Automated Tombstone Callback
▼
[ Database: device_registrations ]
UPDATE device_registrations
SET is_active = FALSE,
tombstoned_at = NOW(),
tombstone_reason = '410_UNREGISTERED'
WHERE push_token = '0x9f4a...';
Continuing to dispatch notifications to tokens that have returned a 410 or UNREGISTERED error is a severe architectural antipattern. Apple actively throttles or terminates HTTP/2 connections from providers that repeatedly flood APNs with invalid tokens. Furthermore, dispatching to stale tokens creates useless queue backlog, increases egress costs, and pollutes analytics.
Multi-Device Fanout and Session Invalidation
In modern consumer and enterprise applications, a single user frequently maintains active sessions across multiple devices: an iPhone, an Android tablet, and a desktop browser.
The backend token registry must support a one-to-many relationship between a user identity and device tokens, while handling session security:
- Multi-Device Fanout: When a message event occurs for
user_id = 42, the dispatch engine queries all active tokens registered touser_id = 42and broadcasts the notification in parallel. - Logout Isolation: When a user logs out of an application on Device , the client app must immediately notify the backend to detach the session (
user_id = NULLoris_active = FALSE). If this step is omitted, sensitive notifications intended for the previous user will be delivered to whoever next uses that physical device.
PostgreSQL Production Schema for Device Token Management
The following schema demonstrates production-grade token tracking, supporting multi-device fanout, atomic upsert registration, and immediate tombstoning:
-- Production Device Token Registry Schema
CREATE TYPE device_platform AS ENUM ('ios', 'android', 'web');
CREATE TABLE device_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
device_fingerprint VARCHAR(128) NOT NULL,
platform device_platform NOT NULL,
push_token TEXT NOT NULL,
app_version VARCHAR(32) NOT NULL,
os_version VARCHAR(32) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tombstoned_at TIMESTAMPTZ,
tombstone_reason VARCHAR(64),
CONSTRAINT uq_platform_token UNIQUE (platform, push_token)
);
-- Optimized index for high-throughput user fanout lookups
CREATE INDEX idx_device_registrations_user_active
ON device_registrations (user_id, platform)
WHERE is_active = TRUE;
-- Index for background cleanup of tombstoned records
CREATE INDEX idx_device_registrations_tombstoned
ON device_registrations (tombstoned_at)
WHERE is_active = FALSE;
-- 1. Atomic Upsert for Device Registration (Invoked on app launch / token refresh)
-- Handles re-registration, OS upgrades, and switching user accounts on the same device
INSERT INTO device_registrations (
user_id,
device_fingerprint,
platform,
push_token,
app_version,
os_version,
is_active,
updated_at,
last_seen_at,
tombstoned_at,
tombstone_reason
) VALUES (
$1, $2, $3, $4, $5, $6, TRUE, NOW(), NOW(), NULL, NULL
)
ON CONFLICT (platform, push_token)
DO UPDATE SET
user_id = EXCLUDED.user_id,
device_fingerprint = EXCLUDED.device_fingerprint,
app_version = EXCLUDED.app_version,
os_version = EXCLUDED.os_version,
is_active = TRUE,
updated_at = NOW(),
last_seen_at = NOW(),
tombstoned_at = NULL,
tombstone_reason = NULL;
-- 2. Immediate Token Tombstoning (Invoked on APNs 410 or FCM UNREGISTERED)
UPDATE device_registrations
SET
is_active = FALSE,
tombstoned_at = NOW(),
tombstone_reason = $3,
updated_at = NOW()
WHERE platform = $1 AND push_token = $2;
-- 3. Session Invalidation on User Logout
-- Unlinks user_id from the device while preserving registration records for diagnostics
UPDATE device_registrations
SET
user_id = NULL,
updated_at = NOW()
WHERE user_id = $1 AND device_fingerprint = $2;
4. Payload Architecture: Display Banners vs. Background Execution
Mobile operating systems enforce a strict architectural boundary between notifications intended for human display and notifications intended for silent background execution.
[ Push Payload ]
│
┌───────────────────┴───────────────────┐
▼ ▼
[ Display / Alert Push ] [ Silent / Background Push ]
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ • APNs: aps.alert dictionary │ │ • APNs: content-available: 1 │
│ • FCM: notification object │ │ • FCM: data object only │
│ • Rendered by Mobile OS UI │ │ • Wakes App in background │
│ • High delivery priority │ │ • 30s strict execution limit │
│ • Informs human user │ │ • Power throttled by OS │
└──────────────────────────────┘ └──────────────────────────────┘
Display Notifications (Alerts)
Display notifications present banners, sounds, and badge numbers directly to the user. The mobile operating system renders these elements without waking up the main application process.
- APNs Structure: Must contain an
apsdictionary withalert(title, subtitle, body),sound, and optionalbadge. For thread grouping in the iOS notification center, specifythread-id. - FCM Structure: Contains a top-level
notificationobject withtitleandbody, alongside an optionaldatadictionary for deep links. When the app is in the background on Android, the operating system tray displays the notification directly; only when the user taps the banner is the intent delivered to the target Activity.
Silent / Background Pushes (Data-Only)
Silent notifications wake up the mobile application in the background to execute code—such as prefetching chat history, synchronizing database deltas, or invalidating local caches—without displaying a visual banner.
- APNs Silent Push Rules:
- Must specify
content-available: 1inside theapsdictionary. - Must omit
alert,sound, andbadgekeys entirely. - Must set
apns-push-type: backgroundandapns-priority: 5. - The 30-Second Execution Budget: Once woken, the iOS application delegate method
application(_:didReceiveRemoteNotification:fetchCompletionHandler:)receives exactly 30 seconds of clock time to complete work and invoke the completion handler. If the app fails to invoke the handler or exceeds its CPU budget, iOS terminates the process and deprioritizes future background wake-ups.
- Must specify
- Android FCM Data-Only Pushes:
- Must contain only the
datakey, omitting thenotificationobject. - Delivered to
FirebaseMessagingService.onMessageReceived(). - Foreground Service Launch Restrictions: Starting with Android 12 (API 31), applications running in the background cannot start foreground services from push handlers except under narrow exemption windows.
- Runtime Permissions: Under Android 13+ (API 33), apps cannot display notifications without explicit user approval of the
POST_NOTIFICATIONSruntime permission.
- Must contain only the
Comparison: Display Notifications vs. Silent Pushes
| Dimension | Display / Alert Notifications | Silent / Background Pushes |
| :--- | :--- | :--- |
| Primary Objective | Prompt user attention via visual banners, badges, and sounds | Background cache synchronization, data invalidation, prefetching |
| User Visibility | Rendered immediately by the operating system UI | Completely invisible to the user |
| Platform Payload Key | APNs: aps.alert; FCM: notification | APNs: aps.content-available: 1; FCM: data |
| APNs Headers | apns-push-type: alert, apns-priority: 10 | apns-push-type: background, apns-priority: 5 |
| Execution Budget | Negligible (handled by OS compositor) | Strict maximum (iOS: 30 seconds to call completion handler) |
| Throttling Policy | Minimal throttling if high priority | Aggressive OS throttling based on battery level, Doze, and app usage |
| Common Failure Mode | Stale badge counts, repetitive alerts | Process killed mid-execution; silent drops under Low Power Mode |
5. In-Flight Payload Modification and End-to-End Security
Transmitting cleartext sensitive information—such as two-factor authentication OTP codes, account balances, or private medical details—inside standard push notification payloads represents a severe security and compliance vulnerability.
The Security Hazards of Raw Payloads
- Third-Party Infrastructure Transit: Push payloads pass through external infrastructure (Apple, Google, cellular network routing proxies) where unencrypted data can be logged or inspected.
- Lock Screen Exposure: Notifications displayed on locked device screens can be read by unauthorized individuals without biometric authentication.
- Payload Size Constraints: Both APNs and FCM enforce a hard payload ceiling of 4096 bytes (4 KB). Embedding large JSON payloads, images, or full chat threads causes gateway rejection (
413 PayloadTooLargeorMessageTooBig).
Architectural Solution 1: The Wake-Up Pointer Pattern
Instead of sending confidential data through the push gateway, the backend sends an opaque resource pointer:
{
"aps": {
"alert": {
"title": "New Encrypted Message",
"body": "You have received a new secure document."
}
},
"data": {
"resource_type": "secure_message",
"resource_id": "8fa24b91-4c11-4f16-b8c2-a7d1883fa09e"
}
}
When the user taps the notification, the application launches, establishes an authenticated TLS session with your API servers, verifies authorization headers, and retrieves the sensitive payload directly from the database.
Architectural Solution 2: In-Flight Client Decryption (Notification Service Extension)
For applications requiring end-to-end encryption (E2EE) with rich display banners—such as secure messaging platforms—iOS provides the UNNotificationServiceExtension.
This extension runs as an isolated companion process that intercepts push notifications before they are presented on the device lock screen:
+-----------------------------------------------------------------------------------+
| IN-FLIGHT PAYLOAD DECRYPTION VIA NOTIFICATION EXTENSION |
+-----------------------------------------------------------------------------------+
[ Backend Dispatcher ]
│
│ 1. Encrypt Payload using Client Public Key (ECDH)
│ Construct push: { "ciphertext": "a8f1...", "iv": "..." }
│ Set "mutable-content": 1
▼
[ Platform Push Gateway (APNs) ]
│
│ 2. Deliver encrypted payload over APNs TLS
▼
[ Target Device (iOS APNs Daemon) ]
│
│ 3. Intercept push before lock screen rendering
▼
┌─────────────────────────────────────────────────────────┐
│ iOS Notification Service Extension Process │
│ (UNNotificationServiceExtension) │
│ │
│ a. Extract ciphertext & initialization vector │
│ b. Access private key from App Group / Secure Enclave │
│ c. Decrypt message payload in isolated memory │
│ d. (Optional) Download rich media attachment over TLS │
│ e. Mutate UNMutableNotificationContent (title, body) │
│ f. Complete within 30-second system execution window │
└────────────────────────────┬────────────────────────────┘
│
│ 4. Deliver mutated content
▼
[ iOS Lock Screen / Notification Center ]
"Alice: Hey, are you free for lunch?"
(Cleartext rendered only on device display)
To activate this pipeline:
- The backend sets
"mutable-content": 1in theapspayload dictionary. - The iOS system intercepts the packet and instantiates
UNNotificationServiceExtension. - The extension retrieves the user's private decryption key from the shared App Group Keychain.
- The extension decrypts the ciphertext, mutates the notification content (
titleandbody), optionally downloads rich media attachments (images or audio), and passes the completedUNNotificationContentto the system notification center. - If decryption fails or the 30-second execution timer expires, the operating system falls back to displaying the original fallback text specified in the raw payload.
6. Building a High-Throughput Dispatch Engine
At scale, an application backend must deliver millions of notifications within minutes during breaking news, order status updates, or live sporting events. A naive implementation that makes synchronous HTTP calls directly inside the main application request loop will rapidly exhaust thread pools and collapse upstream services.
Decoupled Worker Pipeline
A production dispatch architecture decouples event generation from delivery using a distributed message broker:
- Ingestion Layer: Microservices publish event envelopes (
user_id,event_type,payload) to a partitioned Apache Kafka topic or RabbitMQ queue. - Worker Pool: Sharded consumer workers consume messages in batches, query the token registry database for active devices, and group dispatch jobs by platform provider.
- HTTP/2 Connection Pooling for APNs: Opening a fresh TLS handshake for every notification sent to APNs destroys throughput; TLS handshakes require multiple round trips and heavy CPU negotiation. A production dispatcher maintains persistent HTTP/2 connection pools. Because HTTP/2 multiplexes hundreds of concurrent streams over a single TCP connection, a pool of 10 to 20 persistent connections can sustain thousands of dispatches per second.
- Coalescing and De-duplication:
When events occur in rapid succession—such as five chat messages arriving within ten seconds—dispatch workers assign a shared
apns-collapse-id(APNs) andcollapse_key(FCM). The platform gateway collapses unread banners on the device, presenting only the latest notification rather than buzzing the user's phone repeatedly.
Production TypeScript Push Notification Dispatcher
The following TypeScript implementation provides a robust dispatch engine that handles APNs and FCM HTTP v1 dispatch, enforces exponential backoff on transient gateway errors, and automatically executes database tombstoning when platform invalidation codes are encountered.
/**
* Production-Grade Push Notification Dispatcher
* Demonstrates HTTP/2 payload structuring, platform error handling,
* token invalidation callbacks, and exponential backoff retry logic.
*/
export interface NotificationPayload {
recipientToken: string;
platform: 'ios' | 'android';
title?: string;
body?: string;
dataPayload?: Record<string, string>;
collapseKey?: string;
priority?: 'high' | 'normal';
timeToLiveSeconds?: number;
}
export interface DispatchResult {
success: boolean;
messageId?: string;
tokenInvalidated: boolean;
statusCode?: number;
error?: string;
retryable: boolean;
}
export interface DispatcherConfig {
apnsEndpoint: string; // e.g., 'https://api.push.apple.com' or development sandbox
apnsTopic: string;
apnsJwtToken: string;
fcmProjectId: string;
fcmAccessToken: string;
onTokenInvalidated: (token: string, platform: 'ios' | 'android', reason: string) => Promise<void>;
maxRetries?: number;
initialBackoffMs?: number;
}
export class PushNotificationDispatcher {
private readonly config: DispatcherConfig;
private readonly maxRetries: number;
private readonly initialBackoffMs: number;
constructor(config: DispatcherConfig) {
this.config = config;
this.maxRetries = config.maxRetries ?? 3;
this.initialBackoffMs = config.initialBackoffMs ?? 200;
}
/**
* Dispatches a notification with exponential backoff for retryable errors.
*/
public async dispatchWithRetry(payload: NotificationPayload): Promise<DispatchResult> {
let attempt = 0;
let delay = this.initialBackoffMs;
while (attempt <= this.maxRetries) {
const result = await this.dispatch(payload);
if (result.success || !result.retryable || attempt === this.maxRetries) {
return result;
}
attempt++;
// Full jitter backoff formula: t = rand(0, delay * 2^attempt)
const jitterDelay = Math.floor(Math.random() * (delay * Math.pow(2, attempt)));
await new Promise((resolve) => setTimeout(resolve, jitterDelay));
}
return {
success: false,
tokenInvalidated: false,
error: 'Max retries exhausted',
retryable: false,
};
}
/**
* Routes dispatch to the appropriate platform gateway.
*/
private async dispatch(payload: NotificationPayload): Promise<DispatchResult> {
if (payload.platform === 'ios') {
return this.sendToApns(payload);
} else if (payload.platform === 'android') {
return this.sendToFcm(payload);
}
return {
success: false,
tokenInvalidated: false,
error: `Unsupported platform: ${payload.platform}`,
retryable: false,
};
}
/**
* Dispatches to Apple Push Notification service over HTTP/2.
*/
private async sendToApns(payload: NotificationPayload): Promise<DispatchResult> {
const isAlert = Boolean(payload.title || payload.body);
const url = `${this.config.apnsEndpoint}/3/device/${payload.recipientToken}`;
const headers: Record<string, string> = {
'authorization': `bearer ${this.config.apnsJwtToken}`,
'apns-topic': this.config.apnsTopic,
'apns-push-type': isAlert ? 'alert' : 'background',
'apns-priority': payload.priority === 'high' || isAlert ? '10' : '5',
'content-type': 'application/json',
};
if (payload.collapseKey) {
headers['apns-collapse-id'] = payload.collapseKey;
}
if (payload.timeToLiveSeconds !== undefined) {
const expirationEpoch = Math.floor(Date.now() / 1000) + payload.timeToLiveSeconds;
headers['apns-expiration'] = expirationEpoch.toString();
}
// Construct APNs payload dictionary
const body: Record<string, unknown> = {
...payload.dataPayload,
};
if (isAlert) {
body.aps = {
alert: {
title: payload.title,
body: payload.body,
},
sound: 'default',
'mutable-content': 1,
};
} else {
// Silent background push
body.aps = {
'content-available': 1,
};
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (response.ok) {
const apnsId = response.headers.get('apns-id') ?? undefined;
return {
success: true,
messageId: apnsId,
tokenInvalidated: false,
statusCode: response.status,
retryable: false,
};
}
const responseJson = (await response.json().catch(() => ({}))) as { reason?: string };
const reason = responseJson.reason ?? 'UnknownApnsError';
// Status 410 Unregistered or 400 BadDeviceToken signals terminal token invalidation
if (response.status === 410 || reason === 'BadDeviceToken' || reason === 'Unregistered') {
await this.config.onTokenInvalidated(payload.recipientToken, 'ios', reason);
return {
success: false,
tokenInvalidated: true,
statusCode: response.status,
error: reason,
retryable: false,
};
}
// Transient gateway errors (429 rate limit, 500 server error, 503 service unavailable)
const isRetryable = response.status === 429 || response.status >= 500;
return {
success: false,
tokenInvalidated: false,
statusCode: response.status,
error: reason,
retryable: isRetryable,
};
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
success: false,
tokenInvalidated: false,
error: errorMessage,
retryable: true, // Network-level socket drops are retryable
};
}
}
/**
* Dispatches to Firebase Cloud Messaging using FCM HTTP v1 REST API.
*/
private async sendToFcm(payload: NotificationPayload): Promise<DispatchResult> {
const url = `https://fcm.googleapis.com/v1/projects/${this.config.fcmProjectId}/messages:send`;
const message: Record<string, unknown> = {
token: payload.recipientToken,
data: payload.dataPayload ?? {},
};
if (payload.title || payload.body) {
message.notification = {
title: payload.title,
body: payload.body,
};
}
// Android-specific configuration overrides
message.android = {
priority: payload.priority === 'high' ? 'HIGH' : 'NORMAL',
ttl: `${payload.timeToLiveSeconds ?? 86400}s`,
};
if (payload.collapseKey) {
(message.android as Record<string, unknown>).collapse_key = payload.collapseKey;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.fcmAccessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
});
if (response.ok) {
const responseData = (await response.json()) as { name?: string };
return {
success: true,
messageId: responseData.name,
tokenInvalidated: false,
statusCode: response.status,
retryable: false,
};
}
const errorData = (await response.json().catch(() => ({}))) as {
error?: { status?: string; message?: string; details?: Array<{ errorCode?: string }> };
};
const errorStatus = errorData.error?.status ?? '';
const errorCode = errorData.error?.details?.[0]?.errorCode ?? '';
// FCM returns NOT_FOUND or UNREGISTERED when app is uninstalled or token rotated
if (response.status === 404 || errorStatus === 'NOT_FOUND' || errorCode === 'UNREGISTERED') {
await this.config.onTokenInvalidated(payload.recipientToken, 'android', errorCode || 'UNREGISTERED');
return {
success: false,
tokenInvalidated: true,
statusCode: response.status,
error: errorCode || errorStatus || 'UNREGISTERED',
retryable: false,
};
}
const isRetryable = response.status === 429 || response.status >= 500 || errorStatus === 'UNAVAILABLE';
return {
success: false,
tokenInvalidated: false,
statusCode: response.status,
error: errorData.error?.message ?? `FCM error ${response.status}`,
retryable: isRetryable,
};
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
success: false,
tokenInvalidated: false,
error: errorMessage,
retryable: true,
};
}
}
}
7. Production Verification & Operational Observability
Because push notification pipelines operate across third-party networks, monitoring delivery reliability requires tracking metrics at both the server dispatch layer and the mobile client reception layer.
Key Telemetry Metrics
- Token Churn & Invalidation Rate:
- Calculate the ratio of /
UNREGISTEREDerrors to total dispatch attempts. A sudden spike indicates either an unannounced app release with broken token registration logic or platform certificate expiration.
- Calculate the ratio of /
- Gateway Egress Latency:
- Measure the p95 and p99 latency of HTTP/2 requests to APNs and FCM endpoints. High latency indicates connection pool starvation or transport socket exhaustion.
- Queue Ingestion Lag:
- Track the offset lag on your Kafka or RabbitMQ push dispatch topics. If ingestion lag steadily climbs during campaign events, scale consumer worker instances horizontally.
- End-to-End Delivery Ack Rate:
- Because platform gateways do not confirm physical delivery to the phone, instrument client-side acknowledgement: when the app receives a push or executes
UNNotificationServiceExtension, emit a lightweight analytics event containing themessage_idback to your telemetry ingestor.
- Because platform gateways do not confirm physical delivery to the phone, instrument client-side acknowledgement: when the app receives a push or executes
Production Verification Checklist
Before releasing a push notification architecture to production, verify every operational checkpoint below:
- [ ] Token Registry State Machine:
- [ ] Device registration endpoint implements atomic upsert logic (
ON CONFLICT (platform, push_token) DO UPDATE). - [ ] Logging out of an active account immediately unlinks
user_idfrom the device record. - [ ] APNs
410 Unregisteredand FCMUNREGISTEREDresponses immediately trigger automated database tombstoning.
- [ ] Device registration endpoint implements atomic upsert logic (
- [ ] Payload Discipline & Size Budgets:
- [ ] Raw JSON payloads never exceed 4096 bytes under worst-case Unicode character expansions.
- [ ] Display notifications specify proper platform headers (
apns-push-type: alert,apns-priority: 10). - [ ] Silent background pushes specify
content-available: 1, omit display keys, and setapns-push-type: backgroundwithapns-priority: 5. - [ ] No plaintext PII, authentication tokens, or unencrypted financial data is transmitted in the raw push payload.
- [ ] Worker Pipeline Resilience:
- [ ] APNs dispatcher maintains persistent HTTP/2 connection pooling with multiplexing, avoiding repeated TLS handshakes.
- [ ] FCM dispatcher generates and caches OAuth 2.0 access tokens, refreshing them prior to expiration.
- [ ] Transient gateway errors (429, 500, 503) trigger bounded exponential backoff with full jitter.
- [ ] Rapid-fire notifications leverage
apns-collapse-idandcollapse_keyto prevent device notification flooding.
- [ ] Client Application Handling:
- [ ] iOS
UNNotificationServiceExtensionfinishes execution and calls its content handler well within the 30-second window. - [ ] Android client code adheres to Android 12+ foreground service launch restrictions from background push receivers.
- [ ] Android 13+ runtime notification permissions (
POST_NOTIFICATIONS) are properly requested and handled when denied. - [ ] Client application treats pushes as wake-up nudges, fetching authoritative data from the backend over TLS.
- [ ] iOS
8. Conclusion: Designing for Failure as the Default
Push notification architectures only become brittle when engineers demand guarantees that distributed mobile platforms were never designed to provide. Treating a push notification as a synchronous RPC or a lossless transactional queue introduces hidden coupling that fails under routine real-world conditions: dead batteries, cellular tunnel handoffs, OS battery-saver modes, and token invalidations.
Reliable push notification architecture begins with architectural humility:
- Assume that any individual push packet may be delayed, dropped, or reordered.
- Enforce clear separation between lightweight wake-up nudges and authoritative data fetching.
- Model the device token registry as a dynamic state machine with automated tombstoning.
- Protect user privacy by decrypting payloads in-flight or fetching data over authenticated TLS.
When these boundaries are established, your backend dispatch engine remains high-throughput and cost-effective, while your mobile applications deliver responsive, battery-friendly experiences to users across the globe.


