| name | push-notifications-overview |
| description | End-to-end push notifications on mobile - FCM and APNs architecture, payload shapes, notification vs data messages, permissions, and cross-platform handling in Flutter and React Native. Use when adding, debugging, or rearchitecting push. |
Push Notifications Overview
Instructions
Push notifications are a privileged channel. They drive retention and support real-time features, but every mis-send erodes trust. This skill covers the flow end-to-end and the mistakes that cost engagement.
1. Architecture at 10,000 feet
Server -> FCM (Android, all) -> Device (system tray) -> App handler
-> APNs (iOS) -> Device (system tray) -> App handler
- Android: FCM is the transport. Even if you use Firebase or a third party, the wire is FCM.
- iOS: APNs is the transport. FCM routes to APNs under the hood when you use Firebase.
- Token lifecycle: the device registers, you store
{userId, platform, token} on the server, and invalidate on sign-out or token rotation.
2. Notification vs Data Messages
| Type | Shown by OS | Wakes app | Good for |
|---|
| Notification (display) | Yes | Not always | Marketing, reminders |
| Data (silent) | No | Yes (constrained) | Background sync, badge updates |
| Mixed | Yes | Yes | Most product notifications |
Silent pushes are throttled (APNs apns-priority: 5, background mode; Android high vs normal priority with Doze). Do not rely on them for time-critical delivery.
3. Permissions
- iOS: ask with
UNUserNotificationCenter.current().requestAuthorization(options:). Prompt in a thoughtful moment, not at first launch. Support provisional authorization (.provisional) to deliver quietly until the user opts in.
- Android 13+:
POST_NOTIFICATIONS runtime permission required. If the user denies, the app must still function.
Track permission state and respect it. Never "re-prompt" by navigating to Settings repeatedly.
4. Payload Shapes
{
"message": {
"token": "DEVICE_TOKEN",
"notification": { "title": "New message", "body": "Alex sent you a photo" },
"data": { "conversationId": "c_123", "type": "message.new" },
"android": { "priority": "HIGH", "notification": { "channel_id": "messages" } },
"apns": {
"headers": { "apns-priority": "10", "apns-push-type": "alert" },
"payload": { "aps": { "alert": { "title": "New message", "body": "..." }, "sound": "default", "thread-id": "c_123" } }
}
}
}
Rules:
- Keep payload under 4 KB.
- Put routing info in
data (stable keys, versioned).
- Keep user-visible copy in
notification.
- Always include a
type string in data so clients can dispatch.
5. Client Handling
class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ c: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
Router.shared.handle(notification: userInfo)
}
func application(_ app: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult {
await SyncService.shared.handle(userInfo)
return .newData
}
}
class AppMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(msg: RemoteMessage) {
val type = msg.data["type"] ?: return
when (type) {
"message.new" -> Notifications.showMessage(this, msg)
"sync.pull" -> WorkManager.getInstance(this).enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())
}
}
override fun onNewToken(token: String) { TokenRegistry.sync(token) }
}
// Flutter (firebase_messaging)
FirebaseMessaging.onMessage.listen((msg) => Router.handle(msg.data));
FirebaseMessaging.onMessageOpenedApp.listen((msg) => Router.handle(msg.data));
FirebaseMessaging.onBackgroundMessage(_bg);
messaging().onMessage(async (msg) => Router.handle(msg.data));
messaging().onNotificationOpenedApp((msg) => Router.handle(msg.data));
messaging().setBackgroundMessageHandler(async (msg) => { await sync(msg.data); });
6. Channels, Categories, and Grouping
- Android: declare
NotificationChannels at startup (messages, marketing, security). Users can disable per-channel.
- iOS: use
UNNotificationCategory with actions (Reply, Archive). Use thread-id to group.
- Never send marketing and transactional on the same channel.
7. Deep Linking From Notifications
A notification tap is a deep link with extra context. Reuse the same router (see deep-links-universal-links). Buffer the notification intent if the navigator is not mounted yet.
8. Testing
- APNs: send from
curl with a provider token; use sandbox APNs in development builds.
- FCM:
curl against the v1 HTTP API or use the Firebase console for one-off tests.
- iOS simulator:
xcrun simctl push booted bundle.id payload.apns.
- Android emulator: Firebase Test Lab or real device. Emulator support is limited.
- Write integration tests that assert routing from payload to screen, not the transport.
9. Observability
Track, per notification:
- Sent, delivered (where observable), opened.
- Conversion to the intended action.
- Error classes: invalid token, quota exceeded, payload too large.
Rotate and invalidate stale tokens; batch-delete tokens that return Unregistered for 7 consecutive days.
10. Anti-Patterns
- Marketing blasts on a transactional channel.
- Prompting for permission on first launch before any value is demonstrated.
- Putting user PII in the payload; the OS and intermediaries can see it.
- Relying on silent pushes for time-critical delivery.
- Not versioning
data payload keys.
- Sending the same message to multiple devices of the same user without deduplication.
Checklist