Push Notifications Done Right

Implementing robust and reliable push notification systems for mobile applications requires careful consideration of platform-specific services, message…

Implementing robust and reliable push notification systems for mobile applications requires careful consideration of platform-specific services, message delivery guarantees, and user privacy. While Firebase Cloud Messaging (FCM) is a de-facto standard for Android and Apple Push Notification service (APNs) for iOS, a unified backend strategy coupled with thoughtful client-side handling is essential. This article details the integration patterns, best practices, and common configurations for a production-grade push notification architecture, focusing on delivering relevant, timely messages while respecting user preferences.

Understanding the Core Push Services: FCM and APNs

At the heart of mobile push notifications are two distinct, platform-specific services:

  • Firebase Cloud Messaging (FCM): A cross-platform messaging solution provided by Google, primarily for Android, but also supporting iOS and web. FCM acts as an intermediary, delivering messages from your application server to client apps via Google's infrastructure. It handles message queuing, retries, and fan-out. Android devices generally maintain a persistent connection to Google's servers for message reception.
  • Apple Push Notification service (APNs): Apple's dedicated service for delivering notifications to iOS, iPadOS, macOS, tvOS, and watchOS devices. APNs is a highly optimized, low-latency service designed for battery efficiency. Messages are delivered over a secure, authenticated channel directly from your provider server to Apple's servers, which then forward them to the target device.

Both services operate on a similar principle: your application server sends a message payload to the respective push service endpoint, which then attempts to deliver it to the client device. The client device, upon installation, registers with its platform's push service to obtain a unique device token (FCM registration token or APNs device token). This token is then sent to your application server and stored, linking the user to their specific device for future notification targeting.

Backend Integration: Unifying FCM and APNs

A common approach is to abstract away the differences between FCM and APNs on your backend. Your application server should store both FCM and APNs tokens for each user and dispatch messages to the appropriate endpoint based on the device platform.

FCM Integration (HTTP v1 API)

Google recommends using the FCM HTTP v1 API for sending messages due to its enhanced security, flexibility, and extensibility compared to the legacy HTTP and XMPP APIs. Authentication is handled via OAuth 2.0 with a service account key.


POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send
Content-Type: application/json
Authorization: Bearer YOUR_ACCESS_TOKEN

{
  "message": {
    "token": "DEVICE_REGISTRATION_TOKEN_FROM_CLIENT",
    "notification": {
      "title": "New Message",
      "body": "You have a new message from John Doe."
    },
    "data": {
      "type": "chat_message",
      "sender_id": "12345",
      "message_id": "98765"
    },
    "android": {
      "priority": "HIGH",
      "notification": {
        "channel_id": "chat_channel",
        "sound": "default"
      }
    },
    "apns": {
      "payload": {
        "aps": {
          "alert": {
            "title": "New Message",
            "body": "You have a new message from John Doe."
          },
          "sound": "default",
          "badge": 1,
          "content-available": 1 // For silent notifications
        }
      },
      "headers": {
        "apns-push-type": "alert", // or 'background'
        "apns-priority": "10", // 5 for background, 10 for immediate
        "apns-topic": "BUNDLE_ID" // For APNs directly via FCM
      }
    }
  }
}

Key parameters:

  • token: The specific device registration token.
  • notification: Standard display properties (title, body).
  • data: Custom key-value pairs, handled by the client app regardless of app state.
  • android: Android-specific configuration (priority, notification channel, sound).
  • apns: iOS-specific configuration, allowing direct APNs payload specification within FCM. This is useful for sending to iOS devices via FCM.

APNs Integration (HTTP/2 API)

APNs uses an HTTP/2-based API for sending notifications. Authentication is typically done via token-based authentication (JWT) using a .p8 private key, which is more flexible than certificate-based authentication.


// Example using Curl for APNs HTTP/2
// Ensure you have generated your JWT token and have your .p8 key.

curl -v \
--header "apns-topic: com.yourcompany.yourapp" \
--header "apns-priority: 10" \
--header "apns-push-type: alert" \
--header "authorization: bearer YOUR_GENERATED_JWT" \
--data '{"aps":{"alert":"Hello from APNs!","sound":"default","badge":1}}' \
--http2 \
https://api.push.apple.com/3/device/DEVICE_APNS_TOKEN

Key APNs HTTP/2 Headers:

  • apns-topic: Your app's bundle ID (e.g., com.example.yourapp). This is crucial for routing.
  • apns-priority: 10 for immediate delivery (default, for user-visible notifications), 5 for background delivery (throttled, for silent updates).
  • apns-push-type: alert for user-visible, background for silent notifications, voip for VoIP calls. Available since iOS 13.
  • authorization: Bearer token containing your JWT.
  • apns-expiration: Timestamp for when the notification is no longer valid (optional).
  • apns-id: A unique UUID for the notification (optional, recommended for logging).

APNs Payload Structure (aps dictionary):

  • alert: Dictionary or string for the notification UI (title, body, subtitle).
  • sound: Sound file name or default.
  • badge: Number to display on the app icon.
  • content-available: Set to 1 for background notifications (silent pushes).
  • mutable-content: Set to 1 to enable rich notification modification.
  • category: Identifier for notification actions.

For silent notifications, send a payload with "content-available": 1 and no alert, sound, or badge. On iOS, these wakes up the app in the background for a short period (around 30 seconds) to perform tasks like fetching new data.

Client-Side Handling: Registration and Message Processing

Device Token Registration

On application startup, your client app (Android/iOS) must register with its respective push service to obtain a device token. This token needs to be securely sent to your application server and associated with the user's account.

Android (Kotlin, Firebase Messaging Library):


FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
    if (!task.isSuccessful) {
        Log.w(TAG, "Fetching FCM registration token failed", task.exception)
        return@addOnCompleteListener
    }
    val token = task.result
    // Send this token to your application server
    Log.d(TAG, "FCM Token: $token")
    sendRegistrationToServer(token)
}

iOS (Swift, UserNotifications framework):


// In AppDelegate.swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
    let token = tokenParts.joined()
    print("APNs Device Token: \(token)")
    // Send this token to your application server
    sendRegistrationToServer(token)
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print("Failed to register for remote notifications: \(error.localizedDescription)")
}

Remember to request user authorization for notifications on iOS using UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]).

Message Reception and Processing

Client applications need to handle incoming push messages, whether the app is in the foreground, background, or terminated. This involves parsing the payload and taking appropriate action.

  • Android (FirebaseMessagingService): Extends FirebaseMessagingService to override onMessageReceived() for handling foreground messages and data messages. Background messages with notification payload are handled by the system and trigger the onMessageReceived() callback only when the user taps the notification.
  • iOS (UNUserNotificationCenterDelegate): Implements UNUserNotificationCenterDelegate methods like userNotificationCenter(_:willPresent:withCompletionHandler:) for foreground notifications and userNotificationCenter(_:didReceive:withCompletionHandler:) for handling user taps on notifications. Background silent pushes wake up the application(_:didReceiveRemoteNotification:fetchCompletionHandler:) method in AppDelegate.

User Preferences and Opt-Out Mechanisms

Critical for good user experience and compliance, applications must provide granular control over notification categories.

Per-Category Opt-Out

Users should be able to disable specific types of notifications without disabling all of them. This is typically managed on your application server, linked to user preferences.

  • Backend Implementation: Your user profile should include notification settings, e.g., {"marketing_emails": true, "new_message_alerts": true, "promo_notifications": false}. When sending a push, your backend checks these preferences.
  • Android Notification Channels: Utilize Notification Channels (API Level 26+) to group notifications. Users can control channel settings directly from the Android system settings. Your app defines channels like "Chat Messages", "Promotions", "Account Activity".

// Android: Create a notification channel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val channelId = "chat_channel"
    val channelName = "Chat Messages"
    val importance = NotificationManager.IMPORTANCE_HIGH
    val channel = NotificationChannel(channelId, channelName, importance).apply {
        description = "Notifications for new chat messages"
        // Optional: customize sound, vibration, etc.
    }
    val notificationManager: NotificationManager =
        getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    notificationManager.createNotificationChannel(channel)
}
// When sending a notification, set its channelId
val notificationBuilder = NotificationCompat.Builder(this, "chat_channel")
    .setSmallIcon(R.drawable.ic_notification)
    .setContentTitle("New Message")
    .setContentText("You have a new message.")
    .setPriority(NotificationCompat.PRIORITY_HIGH)
    .setAutoCancel(true)
  • iOS Notification Settings: iOS offers less granular control natively than Android channels. Your app must provide an in-app settings screen where users can toggle notification categories. When a user disables a category, your backend simply stops sending those messages.

It's crucial to map your backend notification categories to Android Notification Channels and present clear in-app options for iOS users. Upon changing settings, the client should inform the server to update the user's preferences.

Common Pitfalls and Troubleshooting

  • Expired Tokens: Device tokens can expire or become invalid (e.g., app uninstalled, user opts out of notifications system-wide). Your backend must handle token invalidation errors (e.g., HTTP 400 from FCM/APNs) and remove invalid tokens from your database.
  • Message Throttling: Both FCM and APNs can throttle messages, especially silent notifications or those sent with low priority. Design your system to handle potential delays.
  • Payload Size Limits: FCM has a 4KB limit for notification/data payloads. APNs has a 4KB limit for non-VoIP notifications and 5KB for VoIP. Exceeding these limits will result in delivery failure.
  • Notification Permissions: Always check and request notification permissions on both platforms. Handle scenarios where permissions are denied.
  • Foreground vs. Background Handling: Remember that push notifications behave differently depending on the app's state. Ensure your client-side logic correctly handles messages in all states.
  • APNs Sandbox vs. Production: APNs has separate endpoints for development (sandbox) and production environments. Ensure your backend and client are configured to use the correct environment. FCM uses a single endpoint but requires different API keys/project IDs for different environments if you isolate them.
  • Firebase Project Configuration: For Android, ensure your google-services.json is correctly configured. For iOS with FCM, ensure your GoogleService-Info.plist is accurate and that APNs authentication keys are uploaded to your Firebase project.
  • Security: Never include sensitive information directly in the notification payload. Instead, use a notification to trigger a secure API call to fetch fresh data.

Back to the knowledge base · Ask the AI assistant