Production engineering guide for mobile push notifications covering patterns, implementation strategies, and operational best practices.
Mobile push notifications are the primary mechanism for re-engaging users with a mobile app after they’ve left it. They deliver timely, actionable messages from a server to a user’s device, even when the app is closed or backgrounded. This is essential when users expect real-time updates—such as messages, alerts, or status changes—without needing to open the app. They matter most during onboarding, when driving feature adoption, in real-time collaboration apps, and for time-sensitive content like news, events, or e-commerce deals.
Push notifications are sent via a platform-specific service (Apple Push Notification Service, Firebase Cloud Messaging, or Microsoft Azure Notification Hubs), but the underlying flow is consistent across all systems. The sender—typically a backend service—initiates a notification by sending a payload to a notification service endpoint using HTTP. The service then forwards the notification to the user’s device, which wakes up, displays the alert, and may trigger a background task.
The delivery pipeline begins when the mobile app registers with the platform’s push service. On iOS, the app calls registerForRemoteNotifications() on UIApplication. On Android, FirebaseMessagingService handles registration via FirebaseMessaging.getInstance().getToken(). Both processes result in a unique device token (also called a registration token), which the app stores and sends to the backend. The backend stores this token in a database, indexed by user ID.
When a notification is to be sent, the backend constructs a JSON payload and sends it to the platform API using POST to a well-known endpoint.
POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: Bearer YOUR_SERVER_KEY
Content-Type: application/json
{
"to": "device-token-12345",
"notification": {
"title": "New Message",
"body": "Alice sent you a message.",
"icon": "ic_notification.png",
"click_action": "OPEN_CHAT",
"sound": "default"
},
"data": {
"message_id": "msg-123",
"chat_id": "chat-456",
"priority": "high"
}
}
to specifies the target device token.notification is a standard, platform-rendered payload. It’s visible even if the app is not running.data is a custom, application-specific payload that the app can use when launching or processing the notification.The response includes a status code and a JSON body:
{
"message_id": "1234567890",
"success": true,
"canonical_ids": 0,
"results": [
{
"message_id": "1234567890",
"registration_id": "device-token-12345",
"success": true,
"error": null
}
]
}
A 200 OK indicates the notification was accepted. A 400 Bad Request might return:
{
"error": "InvalidRegistration",
"details": "Registration token is malformed or expired."
}
When the app is in the foreground, the platform delivers the notification directly to the app via a callback. On iOS, application(_:didReceiveRemoteNotification:fetchCompletionHandler:) is called. On Android, onMessageReceived() in FirebaseMessagingService is invoked.
When the app is in the background or closed, the platform launches the app and passes the notification data to the app’s entry point. The app should handle both cases.
In iOS, the app can define UNNotificationContent and UNNotificationTrigger to configure how notifications appear. The click_action field in the notification payload maps to UNNotificationAction identifiers, enabling rich interactions.
On Android, the click_action field maps to an Intent action. For example, setting "click_action": "OPEN_CHAT" triggers an Intent with action = "OPEN_CHAT" and extras populated from the data object.
The backend must store and manage device tokens. A typical schema includes:
CREATE TABLE device_tokens (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
device_token TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
app_version TEXT,
last_seen TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (user_id, platform)
);
When a user logs in on a new device, the app registers with the platform and sends the device token to the backend via POST /api/v1/devices.
{
"device_token": "fcm-token-abc123",
"platform": "android",
"app_version": "2.3.1"
}
The backend inserts or updates the record and triggers a push for the new device.
To reduce battery and network usage, mobile platforms use background fetch and push notification batching. On iOS, the system schedules fetches based on app activity and network availability. The app can request a background fetch via beginBackgroundTask(withName:expirationHandler:).
For performance, the data payload should be minimal and structured. Keys like message_id, chat_id, and priority are used to trigger background processing. For example, a high-priority message might trigger a network fetch to sync the chat.
The most common failure is a missing or expired device token. If the backend sends a notification to a token that no longer exists (e.g., due to app updates, OS upgrades, or device resets), the platform returns an InvalidRegistration error, and the backend may need to re-register the device.
Another subtle failure is the duplicate registration. When the app re-registers on startup, it may send the same token multiple times. The backend must deduplicate using user_id and platform as the key. If a new token arrives and the old one is still valid, the platform may send to both, resulting in duplicate notifications.
A critical edge case is notification loss during app startup. If the app is not running and a notification arrives, the platform delivers it, but the app may miss it if it takes too long to start. To mitigate this, the app should include content_available: true in the notification payload.
"notification": {
"title": "New Message",
"body": "Alice sent you a message.",
"content_available": true
}
When content_available is true, iOS triggers a background fetch as soon as the app launches, allowing the app to fetch full data.
For data sync or preloading, apps use silent notifications—notifications with no visible alert but with a data payload. These are sent with content_available: true and mutable_content: true (iOS) or priority: "high" (Android).
A silent notification might look like:
{
"to": "device-token-12345",
"data": {
"sync_type": "full",
"timestamp": "2024-03-15T10:00:00Z"
},
"notification": {
"title": "Syncing Data",
"body": "Fetching latest data in the background.",
"content_available": true,
"mutable_content": true
}
}
On iOS, the app receives this in application(_:didReceiveRemoteNotification:fetchCompletionHandler:) and can perform background work. On Android, FirebaseMessagingService.onMessageReceived() is triggered, and the app can start a WorkManager task.
When a user taps a notification, the app should update its state. The data payload is key here. A message_id can be used to highlight the specific message in a list, and a chat_id can navigate to a chat screen.
The app should also handle notification dismissal. On iOS, UNUserNotificationCenter allows the app to track when a notification is dismissed. On Android, the app can listen for NotificationChannel events and update the UI accordingly.
On iOS, the app can define multiple UNNotificationCategory objects to group actions. For example:
"notification": {
"title": "New Message",
"body": "Alice sent you a message.",
"category": "message"
}
The platform then displays a set of actions (e.g., Reply, Mark as Read, Forward) based on the category. The app must register these categories during startup.
On Android, NotificationChannel groups notifications by purpose. The app should create channels for different types—e.g., message, alert, update. These channels define default settings like importance, vibration, sound, and icon.
click_action: Without it, tapping a notification may not navigate to the correct screen.data structure: A key like chat_id might be nested under extra instead of being top-level, causing the app to miss it.To monitor delivery, the backend should log every notification sent, including message_id, device_token, status, and response. A common failure is a 401 Unauthorized from FCM, indicating an expired or invalid server key. The response body often includes:
{
"error": "InvalidCredentials",
"details": "The provided server key is expired or malformed."
}
To debug, engineers use curl to send test notifications:
curl -X POST \
-H "Authorization: Bearer YOUR_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "device-token-12345",
"notification": {
"title": "Test Notification",
"body": "This is a test."
}
}' \
https://fcm.googleapis.com/fcm/send
They validate the response and check logs to identify delivery issues.
notification for appearance and data for behavior.content_available: true for background processing.user_id and platform as keys.Push notifications are not just a feature—they are a communication channel. When done well, they make mobile apps feel alive, responsive, and deeply integrated into the user’s workflow.
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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy