Stackvora.tech

Push Notifications: APNs and FCM Best Practices

Effective push notification strategies are crucial for mobile application engagement and user experience. This article delves into the technical…

Effective push notification strategies are crucial for mobile application engagement and user experience. This article delves into the technical intricacies and best practices for implementing push notifications using Apple Push Notification service (APNs) for iOS and Firebase Cloud Messaging (FCM) for Android, covering aspects from initial setup to advanced payload management and error handling.

Understanding APNs and FCM Architectures

APNs and FCM operate on similar principles but have distinct implementations. Both act as intermediaries, allowing application servers to send messages to devices without maintaining persistent connections to every individual device.

Apple Push Notification service (APNs)

APNs is Apple's proprietary push notification service for iOS, iPadOS, macOS, watchOS, and tvOS. It uses a secure, certificate-based authentication mechanism. When an app registers for push notifications, the device receives a unique device token, which is then sent to the app's backend server. This token is crucial for targeting specific devices.

  • Provider: Your application server.
  • APNs: Apple's push service.
  • Device: The end-user's Apple device.
  • Communication Protocol: HTTP/2-based APNs provider API. Prior to iOS 13/macOS 10.15, a legacy binary protocol was also supported but is now deprecated.
  • Authentication:
    • Token-based (recommended): Uses JSON Web Tokens (JWTs) signed with a private key. This is the preferred method for its flexibility and ease of management. Key ID, Team ID, and Bundle ID are required.
    • Certificate-based (legacy): Requires a .p12 certificate generated from an Apple Developer account. Separate certificates are needed for development and production environments.

Firebase Cloud Messaging (FCM)

FCM, formerly Google Cloud Messaging (GCM), is Google's cross-platform messaging solution for Android, iOS, and web applications. While it can deliver to iOS devices, it routes through APNs for that platform. For Android, FCM handles both upstream (device to server) and downstream (server to device) messages directly.

  • App Server: Your application backend.
  • FCM Backend: Google's push service.
  • Device: The end-user's Android or iOS device.
  • Communication Protocol: HTTP v1 API (recommended) or legacy HTTP and XMPP protocols. The HTTP v1 API is JSON-based and uses OAuth2 for authentication.
  • Authentication:
    • OAuth 2.0 with Service Accounts: Uses a private key file (JSON format) generated from the Google Cloud Console. This provides secure, granular access.

Client-Side Implementation and Device Token Management

The first step on the client side is to request user permission and obtain a device token.

Requesting Permissions

As a best practice, request notification permissions only after demonstrating the value of your application. An immediate prompt on first launch can be jarring and lead to users denying permissions. For example, for a messaging app, prompt after the user successfully sends their first message.

// iOS (Swift - in AppDelegate or similar)
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UNUserNotificationCenter.current().delegate = self // For foreground notifications
    return true
}

func requestNotificationAuthorization() {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        if granted {
            DispatchQueue.main.async {
                UIApplication.shared.registerForRemoteNotifications()
            }
        } else if let error = error {
            print("Notification authorization denied: \(error.localizedDescription)")
        }
    }
}

// Android (Kotlin - in Activity or Service)
// For Android 13 (API 33) and above, explicit permission is required
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), NOTIFICATION_PERMISSION_REQUEST_CODE)
    }
}
// For FCM, token is usually handled by FirebaseMessagingService
class MyFirebaseMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        Log.d(TAG, "Refreshed token: $token")
        sendRegistrationToServer(token)
    }
}

Device Token Lifecycle

Device tokens can change. They might refresh if the app is reinstalled, restored to a new device, or if the underlying OS changes significantly. Your backend must be prepared to handle token updates and invalidate old tokens. Store tokens securely and associate them with a user ID.

Server-Side Payload Construction and Delivery

The core of push notifications lies in the payload sent from your server to APNs/FCM. Payloads are JSON dictionaries with specific keys.

APNs Payload Structure (HTTP/2 Provider API)

APNs payloads have a maximum size of 4KB (4096 bytes). Key fields include:

  • aps: Dictionary for alert properties (title, body, sound, badge, category, content-available, mutable-content, thread-id).
  • custom-data: Any other custom key-value pairs your app needs.
POST /3/device/<device-token> HTTP/2
Host: api.development.push.apple.com
Apns-Topic: com.yourcompany.yourapp
Apns-Push-Type: alert
Apns-Expiration: 0
Apns-Priority: 10
Apns-Id: <optional-UUID>
Authorization: bearer <APNs-JWT-Token>

{
    "aps": {
        "alert": {
            "title": "New Message from Alex",
            "body": "Hey, are you free for a call around 3 PM today?"
        },
        "badge": 1,
        "sound": "default",
        "category": "MESSAGE_REPLY",
        "thread-id": "chat-room-123"
    },
    "custom_data": {
        "message_id": "msg-xyz789",
        "sender_id": "alex-456"
    }
}

Apns-Push-Type is mandatory since HTTP/2. Use alert for user-visible notifications, background for silent pushes, voip for VoIP calls, complication for watchOS, fileprovider for File Provider extensions, and mdm for Mobile Device Management.

FCM Payload Structure (HTTP v1 API)

FCM payloads have a maximum size of 4KB for notification messages and 4KB for data messages. FCM distinguishes between "notification" messages (handled by the system when the app is in the background/killed) and "data" messages (always handled by the app). A combined message includes both.

POST https://fcm.googleapis.com/v1/projects/your-project-id/messages:send
Content-Type: application/json
Authorization: Bearer <FCM-OAuth2-Token>

{
  "message": {
    "token": "<device-token>",
    "notification": {
      "title": "New Message from Alex",
      "body": "Hey, are you free for a call around 3 PM today?"
    },
    "data": {
      "message_id": "msg-xyz789",
      "sender_id": "alex-456",
      "click_action": "FLUTTER_NOTIFICATION_CLICK" // For Android to open specific activity
    },
    "android": {
      "priority": "HIGH",
      "ttl": "86400s", // 24 hours
      "notification": {
        "channel_id": "chat_channel",
        "click_action": "OPEN_ACTIVITY_CHAT",
        "tag": "chat-room-123" // For grouping
      }
    },
    "apns": {
      "headers": {
        "apns-priority": "10",
        "apns-push-type": "alert",
        "apns-expiration": "0",
        "apns-topic": "com.yourcompany.yourapp"
      },
      "payload": {
        "aps": {
          "alert": {
            "title": "New Message from Alex",
            "body": "Hey, are you free for a call around 3 PM today?"
          },
          "badge": 1,
          "sound": "default",
          "category": "MESSAGE_REPLY",
          "thread-id": "chat-room-123"
        }
      }
    }
  }
}

FCM also supports sending to topics (/topics/news) or conditions ("'stock_client' in topics || 'marketing' in topics") instead of individual tokens.

Advanced Notification Features and Best Practices

Silent Notifications (Background Updates)

Silent pushes are ideal for background content updates, syncing data, or pre-fetching resources without displaying an alert to the user. This improves perceived performance when the user opens the app.

  • APNs: Include "content-available": 1 in the aps dictionary and omit the alert, sound, and badge keys. Set Apns-Push-Type: background and Apns-Priority: 5. On iOS, the system provides a small execution window (typically 30 seconds) for your app to perform tasks.
  • FCM: Send a "data" message without a "notification" block. On Android, the message is delivered to your FirebaseMessagingService.onMessageReceived() callback regardless of app state. Ensure your app handles the background processing efficiently.

Grouping Notifications with Thread IDs / Tags

To prevent users from being overwhelmed, group related notifications. For example, multiple messages in a single chat thread should collapse into one notification.

  • APNs: Use the thread-id key within the aps dictionary. iOS will group notifications with the same thread-id.
  • FCM (Android): Use the tag key in the Android-specific notification payload. Android groups notifications with the same tag if they belong to the same notification channel.

Handling Notification Actions and Categories

Actionable notifications allow users to interact directly from the notification UI (e.g., "Reply," "Archive").

  • APNs: Define UNNotificationCategory objects with associated UNNotificationActions on the client side. Include the category key in your payload's aps dictionary, matching the category identifier defined in your app.
  • FCM (Android): Define NotificationCompat.Actions and associate them with a NotificationCompat.Builder when creating the notification. Use PendingIntents to trigger specific app actions. For deep linking, include a click_action in the data payload or set a deeplink in the android.notification block for newer Android versions.

Notification Channels (Android 8.0+)

Android introduced Notification Channels (API level 26+) to give users fine-grained control over notification types. Your app must categorize all notifications into channels.

// Android (Kotlin)
private fun createNotificationChannel() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val name = getString(R.string.channel_name)
        val descriptionText = getString(R.string.channel_description)
        val importance = NotificationManager.IMPORTANCE_HIGH
        val channel = NotificationChannel("chat_channel", name, importance).apply {
            description = descriptionText
            enableLights(true)
            lightColor = Color.BLUE
        }
        val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.createNotificationChannel(channel)
    }
}

When sending an FCM message, specify the channel_id in the android.notification block. If not specified, Android uses the "Default" channel or creates one.

Common Pitfalls and Troubleshooting

  • Expired or Invalid Device Tokens: APNs and FCM provide feedback mechanisms for invalid tokens. Your server must process this feedback to remove or update stale tokens. For APNs, the HTTP/2 API returns 410 Unregistered for expired tokens. For FCM, check the results array in the response for NotRegistered or InvalidRegistration errors.
  • Permissions Denied: Users can revoke notification permissions at any time. Your app should gracefully handle this, perhaps by offering an in-app prompt to guide them to settings.
  • Certificate/Key Mismatches: Ensure you're using the correct APNs certificate (.p12) or token-based authentication key (.p8) for your environment (development vs. production) and the correct FCM service account key for your project.
  • Firewall Issues: Ensure your backend server can reach APNs (api.push.apple.com on port 443, previously 2195/2196) and FCM (fcm.googleapis.com on port 443).
  • Background Execution Limits (iOS): Silent pushes are not guaranteed to be delivered or launch your app in all cases. The system may defer or throttle them based on device conditions (e.g., Low Power Mode). Do not rely on silent pushes for critical, time-sensitive updates. For critical updates, use visible pushes.
  • Incorrect Payload Structure: A malformed JSON payload will result in an error from APNs or FCM. Consult the official documentation for the precise structure for each service and version.
  • Notification Collisions (Android): If you send multiple notifications with the same ID (and tag), they will overwrite each other. Use unique IDs for distinct notifications, or the same ID/tag if you intend to group or update an existing notification.

Back to the knowledge base · Ask the AI assistant