Mobile Push Notification Architecture

Production-grade guide to mobile push notification architecture covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.

Mobile push notification architecture enables real-time, asynchronous delivery of messages from backend services to mobile devices. It matters when apps must update users without requiring them to launch or foreground the app—such as for messaging, alerts, live updates, or marketing campaigns. This architecture spans from message creation to delivery confirmation, with multiple points where latency, reliability, and delivery success can diverge.

Message Creation and Payload Design

Push notifications originate in backend services and are encoded into a platform-specific payload, typically as a JSON object. The structure is defined by the target platform—APNs for iOS, FCM for Android, and unified formats for cross-platform frameworks.

A basic notification message includes:

{
  "to": "device-token-abc123",
  "title": "New Message",
  "body": "You have a new message from Alice.",
  "data": {
    "message_id": "msg-789",
    "sender_id": "user-456",
    "priority": "high",
    "action": "open-chat"
  },
  "notification": {
    "title": "New Message",
    "body": "You have a new message from Alice.",
    "sound": "default",
    "badge": 1
  },
  "android": {
    "notification": {
      "channel_id": "messages",
      "priority": "high",
      "icon": "ic_notification"
    },
    "data": {
      "click_action": "OPEN_CHAT"
    }
  },
  "apns": {
    "headers": {
      "apns-priority": 10,
      "apns-topic": "com.example.app"
    },
    "payload": {
      "aps": {
        "alert": {
          "title": "New Message",
          "body": "You have a new message from Alice."
        },
        "badge": 1,
        "sound": "default",
        "category": "NEW_MESSAGE"
      }
    }
  }
}

The notification object is rendered by the OS when the app is not running, while data is delivered to the app when it is foregrounded or backgrounded. The apns and android objects allow platform-specific overrides.

Key failure mode: confusing notification and data. When a message is delivered while the app is running, the data payload is passed to the app, but notification fields are ignored unless the app explicitly handles the onNotificationReceived callback. The aps dictionary in APNs must include alert, badge, and sound as top-level keys under aps—a common oversight.

Delivery Protocol and Transport

Messages are sent via platform-specific protocols: APNs (Apple Push Notification service) over HTTP/2, FCM (Firebase Cloud Messaging) over HTTP/2 and WebSocket, and a growing set of custom or open protocols.

For APNs, the client opens a persistent HTTP/2 connection to https://api.push.apple.com:443/3/device/{device-token}. Each request is a binary Push message with a header block and a payload.

A minimal APNs push request:

POST /3/device/d8e9a5b4c6f7e8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2 HTTP/2
Host: api.push.apple.com
Authorization: Bearer {jwt-token}
Content-Type: application/json

{
  "aps": {
    "alert": "New message from Alice",
    "badge": 1,
    "sound": "default"
  }
}

The Authorization header carries a Bearer token, typically a JWT signed with the APNs key file (.p8) and issued by Apple.

For FCM, the client sends an HTTP/2 POST to https://fcm.googleapis.com/v1/projects/{project-id}/messages:send.

POST /v1/projects/myapp/messages:send HTTP/2
Host: fcm.googleapis.com
Authorization: Bearer {access-token}
Content-Type: application/json

{
  "message": {
    "token": "device-token-xyz",
    "notification": {
      "title": "New Message",
      "body": "You have a new message from Alice."
    },
    "data": {
      "message_id": "msg-789"
    },
    "android": {
      "notification": {
        "channel_id": "messages"
      }
    },
    "apns": {
      "headers": {
        "apns-priority": 10
      },
      "payload": {
        "aps": {
          "alert": "New message from Alice"
        }
      }
    }
  }
}

Common failure: incorrect apns-priority values. APNs supports 5 (low), 10 (high), and 15 (immediate). A priority of 10 triggers background fetch on iOS, but the payload must be sent with apns-priority: 10 in the headers object. Using aps.priority in the payload is insufficient.

Message Queueing and Batch Processing

Backends queue messages before sending them to the push services. This decouples message creation from delivery and enables batching.

A typical queueing setup uses a message broker such as RabbitMQ or Apache Kafka. Messages are published to a topic like push.notifications with a schema:

{
  "id": "notif-12345",
  "platform": "ios",
  "device_token": "d8e9a5b4c6f7e8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2",
  "message": {
    "title": "New Message",
    "body": "You have a new message from Alice.",
    "data": {
      "message_id": "msg-789"
    }
  },
  "priority": "high",
  "expires_at": "2025-04-05T12:00:00Z",
  "metadata": {
    "campaign_id": "welcome-1",
    "source": "webhook"
  }
}

Workers consume from the queue and batch messages to reduce network overhead. A typical batcher sends up to 1000 messages per HTTP/2 stream to APNs or FCM.

The batch size must balance latency and cost. A batch of 100 messages to APNs can be sent in a single HTTP/2 stream, reducing per-message overhead. But if the batcher waits too long, messages may expire before delivery.

Critical failure mode: message expiry. APNs and FCM support time_to_live (TTL) in seconds. A message with time_to_live: 86400 (24 hours) is stored by the push service until it is delivered or expires. If the device is offline for more than 24 hours, the message is dropped. The expires_at timestamp must be set in the payload or in the HTTP headers.

Delivery Confirmation and Acknowledgement

Push services do not guarantee delivery. The backend must track delivery status and retry failed messages.

APNs returns a response body in application/json with a results array for each message in a batch:

{
  "results": [
    {
      "id": "notif-12345",
      "status": "success",
      "reason": "Processing"
    },
    {
      "id": "notif-12346",
      "status": "failure",
      "reason": "DeviceTokenNotValid",
      "error": "DeviceTokenNotValid",
      "message": "The device token is not valid for this app."
    }
  ]
}

The status can be success, failure, processing, or unavailable. reason explains the outcome, and error provides a machine-readable code.

For FCM, the response includes a name field for each message and a results array:

{
  "name": "projects/myapp/messages/12345",
  "messageId": "12345",
  "results": [
    {
      "messageId": "12345",
      "canonicalRegistrationId": "d8e9a5b4c6f7e8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2",
      "registrationId": "d8e9a5b4c6f7e8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2",
      "error": {
        "code": 400,
        "message": "Invalid registration token",
        "status": "INVALID_TOKEN"
      }
    }
  ]
}

The canonicalRegistrationId indicates that the device has a newer registration token, and the backend must update its device registry.

Failure mode: missing canonicalRegistrationId handling. When a device registers with a new token, FCM returns a canonicalRegistrationId. If the backend does not update the device record, the same message may be sent to both old and new tokens, and old tokens become stale.

Error Handling and Retry Mechanisms

When delivery fails, the backend must retry. Common error types include:

The backend should maintain a push_failures table with columns: id, message_id, device_token, platform, error_code, error_message, retry_count, last_attempted_at, next_retry_at.

A retry worker polls for messages with next_retry_at <= NOW() and retries using exponential backoff.

For APNs, the backend can use the feedback service, which returns a list of expired and unregistered device tokens. The service is accessed via HTTP/2 at https://api.push.apple.com:2197/feedback.

A feedback request:

GET /feedback HTTP/2
Host: api.push.apple.com
Authorization: Bearer {jwt-token}

The response is a binary stream of 256-byte records:

struct FeedbackRecord {
  uint32_t timestamp;  // seconds since Unix epoch
  uint32_t token_length;  // 1-128 bytes
  uint8_t token[128];   // device token
};

The backend reads this stream and marks devices as inactive or unregistered.

Device Registration and Token Management

Devices register with push services during app install or first launch. The app calls the backend to register a device token.

A typical registration request:

POST /api/v1/devices/register HTTP/1.1
Content-Type: application/json

{
  "device_token": "d8e9a5b4c6f7e8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2",
  "platform": "ios",
  "os_version": "17.4",
  "app_version": "1.2.3",
  "push_type": "apns",
  "push_token": "abc123",
  "user_id": "user-456"
}

The backend stores the token and associates it with the user and device. It also sends a push message to the device to verify registration.

If the app supports multiple environments (staging, production), the device token must include the environment in its topic or channel.

When a user logs in, the app can re-register the device, updating metadata and possibly changing the device token.

Failure mode: stale device tokens. A device may receive a new push token from the OS (e.g., after app update), but the backend does not re-register it. As a result, old messages are sent to an outdated token, and the app may fail to deliver messages to the new device.

Scalability and Monitoring

As push volume grows, the system must scale horizontally. The backend should:

A health check response:

{
  "status": "ok",
  "checks": {
    "apns": {
      "status": "healthy",
      "latency_ms": 124,
      "connected": true,
      "active_streams": 12
    },
    "fcm": {
      "status": "warning",
      "latency_ms": 210,
      "connected": true,
      "active_streams": 8
    },
    "queue": {
      "status": "ok",
      "messages_in_queue": 4567,
      "messages_per_second": 12.3,
      "backlog_hours": 1.8
    },
    "failures": {
      "total": 132,
      "last_24h": 89,
      "top_errors": [
        { "code": "DeviceTokenNotValid", "count": 34 },
        { "code": "InvalidToken", "count": 28 }
      ]
    }
  }
}

The backend logs all push-related events to a structured logging system, including:

These logs enable tracing and alerting. A failure alert is triggered when the push.failed rate exceeds 100 per minute over 5 minutes.

High Availability and Disaster Recovery

The push system must survive outages. The backend should:

A disaster recovery plan includes:

#!/bin/bash

# Drain all pending push messages before a deployment

while true; do
  curl -s -X GET http://push-api:8080/api/v1/push/pending | jq -r '.messages[].id' | \
  xargs -I {} curl -X POST http://push-api:8080/api/v1/push/drain/{}/100
  sleep 30
done

This script continuously processes messages in batches of 100 until the queue is empty.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.