# Push Notifications Overview

> 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.

- Skill: `almasumdev/push-notifications-overview` (Agent Skill)
- Install (CLI): `npx skillmds@latest add almasumdev/push-notifications-overview`
- Raw SKILL.md: https://api.skillmd.com/api/skills/almasumdev/push-notifications-overview/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: almasumdev (https://skillmd.com/u/almasumdev)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/almasumdev/push-notifications-overview

---


# 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

```json
// FCM common payload (server)
{
  "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

```swift
// iOS
class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ c: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse) async {
        let userInfo = response.notification.request.content.userInfo
        Router.shared.handle(notification: userInfo)
    }
    // Silent/data push:
    func application(_ app: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult {
        await SyncService.shared.handle(userInfo)
        return .newData
    }
}
```

```kotlin
// Android
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) }
}
```

```dart
// Flutter (firebase_messaging)
FirebaseMessaging.onMessage.listen((msg) => Router.handle(msg.data));
FirebaseMessaging.onMessageOpenedApp.listen((msg) => Router.handle(msg.data));
FirebaseMessaging.onBackgroundMessage(_bg);
```

```tsx
// React Native (@react-native-firebase/messaging)
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 `NotificationChannel`s 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

- [ ] Token registration and invalidation happen on sign-in, sign-out, and token rotation.
- [ ] Permission is requested in a contextual moment, and respected.
- [ ] Payloads are under 4 KB and include a versioned `type` in `data`.
- [ ] Android notification channels are declared; iOS categories/thread-ids are set.
- [ ] Notification tap routes through the same deep-link router.
- [ ] Silent pushes are not used for time-critical delivery.
- [ ] No PII in payloads.
- [ ] Delivery and open metrics are tracked; stale tokens are pruned.
- [ ] Integration tests cover payload-to-screen routing.

