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.

October 9, 202515 min readOpenStair Engineering
Illustration of a mobile application connected to backend services and device capabilities

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:

  1. 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.
  2. 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.
  3. Out-of-Order Arrival: If an event stream issues notification AA followed by notification BB, variable gateway queue latencies and device reconnect timings mean the device operating system may receive and render notification BB before notification AA.

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 include alert, background, voip, complication, fileprovider, mdm, and liveactivity. Mismatched push types result in gateway rejection or dropped delivery.
  • apns-priority: 10 (deliver immediately, wakes device radio; required for alert) or 5 (deliver at a power-conserving time; required for background).
  • 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 of 0 directs 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/sub topic, or a boolean condition combining multiple topics.
  • Platform Overrides: The payload schema allows granular overrides across android, webpush, and apns within a single dispatch request.
  • Android Priority: Can be set to HIGH (wakes device from Doze, invokes background service within platform quota) or NORMAL (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:

  1. App Uninstallation and Reinstallation: Deleting an application invalidates its existing token. Reinstalling the app issues a completely new token string.
  2. 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.
  3. Operating System Upgrades: Major OS upgrades can trigger internal key renegotiations between the platform daemon and gateway servers.
  4. 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 (or 400 BadDeviceToken if malformed).
  • FCM: Returns an error payload containing UNREGISTERED or NOT_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 to user_id = 42 and broadcasts the notification in parallel.
  • Logout Isolation: When a user logs out of an application on Device AA, the client app must immediately notify the backend to detach the session (user_id = NULL or is_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 aps dictionary with alert (title, subtitle, body), sound, and optional badge. For thread grouping in the iOS notification center, specify thread-id.
  • FCM Structure: Contains a top-level notification object with title and body, alongside an optional data dictionary 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: 1 inside the aps dictionary.
    • Must omit alert, sound, and badge keys entirely.
    • Must set apns-push-type: background and apns-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.
  • Android FCM Data-Only Pushes:
    • Must contain only the data key, omitting the notification object.
    • 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_NOTIFICATIONS runtime permission.

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

  1. Third-Party Infrastructure Transit: Push payloads pass through external infrastructure (Apple, Google, cellular network routing proxies) where unencrypted data can be logged or inspected.
  2. Lock Screen Exposure: Notifications displayed on locked device screens can be read by unauthorized individuals without biometric authentication.
  3. 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 PayloadTooLarge or MessageTooBig).

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:

  1. The backend sets "mutable-content": 1 in the aps payload dictionary.
  2. The iOS system intercepts the packet and instantiates UNNotificationServiceExtension.
  3. The extension retrieves the user's private decryption key from the shared App Group Keychain.
  4. The extension decrypts the ciphertext, mutates the notification content (title and body), optionally downloads rich media attachments (images or audio), and passes the completed UNNotificationContent to the system notification center.
  5. 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:

  1. Ingestion Layer: Microservices publish event envelopes (user_id, event_type, payload) to a partitioned Apache Kafka topic or RabbitMQ queue.
  2. Worker Pool: Sharded consumer workers consume messages in batches, query the token registry database for active devices, and group dispatch jobs by platform provider.
  3. 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.
  4. 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) and collapse_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

  1. Token Churn & Invalidation Rate:
    • Calculate the ratio of 410410 / UNREGISTERED errors to total dispatch attempts. A sudden spike indicates either an unannounced app release with broken token registration logic or platform certificate expiration.
  2. 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.
  3. 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.
  4. 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 the message_id back to your telemetry ingestor.

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_id from the device record.
    • [ ] APNs 410 Unregistered and FCM UNREGISTERED responses immediately trigger automated database tombstoning.
  • [ ] 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 set apns-push-type: background with apns-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-id and collapse_key to prevent device notification flooding.
  • [ ] Client Application Handling:
    • [ ] iOS UNNotificationServiceExtension finishes 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.

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.

Related articles

Illustration of a software delivery pipeline from commit through tests and deployment
Mobile

Mobile App Release Management: From Build to Store

October 14, 2025 · 16 min read

A production guide to mobile release management: headless CI/CD code signing, monotonic versioning, staged rollouts, crash symbolication, and kill switches.

MobileApplicationsSoftware EngineeringArchitecture
Illustration of accessible mobile interface patterns with readable controls and inclusive interaction states
Mobile

Designing Mobile Applications for Accessibility

October 13, 2025 · 14 min read

A production guide to mobile accessibility: native screen reader pipelines, semantic trees, WCAG standards, hit target sizing, and dynamic font scaling.

MobileApplicationsSoftware EngineeringArchitecture