How to read this. This is a library question. Your users are app developers, and your product is a small public API plus a backend contract. Sections 1 to 8 are the platform-neutral design with Kotlin sketches. Section 9 maps it to iOS. The design is the same on both; the token, permission and delivery primitives differ.
TL;DR. Treat the device token as a lease the SDK owns end to end: obtain it, bind it to the current user, re-upload it when it changes, and delete it on logout. Treat every push as a hint, never as the data, and pair it with a sync call that heals missed messages. Let the host app own permission timing, UI and deep link targets. Let the SDK own token state, dispatch, dedupe, analytics and coexistence with other push libraries. Keep the public surface tiny, because every method is a compatibility promise.
1. Clarify the requirements (about 5 min)
Good opening questions:
- First-party apps only, or third-party developers? (Assume five internal apps now, external later.)
- Are users logged in? (Yes. A device is bound to a user and unbound on logout.)
- Do other SDKs in the same app use push? (Assume yes.)
Functional: register a device, bind and unbind a user, ask for permission, show notifications, route taps, subscribe to topics, opt out, report delivered and opened events.
| Concern | Target |
|---|---|
| Correctness | Never push to a logged-out user; never lose a token change |
| Reliability | Push is best-effort; recover missed content through sync |
| API stability | No source or binary breaks inside a major version |
| Privacy | Collect the minimum; honor opt-out immediately |
Out of scope for now: backend fan-out, campaign tooling, in-app message UI.
2. High-level architecture (about 10 min)
The key decision: the SDK is a state machine around one record, the device registration. Every event (new token, login, logout, permission change) updates that record, and one worker reconciles it with the backend.
Official architecture mapping: the SDK is a library beside the app's layers; the host wraps it in its own repository so ViewModels never call it directly. Inside,
TokenRegistryis a repository over a local data source (source of truth, lazy writes) and a remote data source. Token upload is business-oriented work (WorkManager); events are queued writes. See architecture recommendations and data layer.
3. Public API and data model
// Sketch: the public surface fits on one screen. login and logout are suspend (one-shot writes);
// onToken and handleMessage stay plain because FCM callbacks must return quickly.
object PushSdk {
fun configure(app: Application, config: PushConfig)
fun onToken(token: String) // host service forwards token changes
fun handleMessage(message: RemoteMessage): Boolean // true if the SDK consumed it
suspend fun login(userId: String)
suspend fun logout() // unbind and delete the token
fun setOptIn(topic: String, enabled: Boolean)
val permissionState: StateFlow<PermissionState> // GRANTED, DENIED, NOT_ASKED
fun setListener(listener: PushListener) // taps and foreground messages
}
// Sketch: one immutable row per install, kept in the local DB (source of truth).
data class DeviceRegistration(
val installId: String, // SDK-generated UUID, stable across token changes
val token: String?,
val userId: String?, // null when logged out
val notificationsEnabled: Boolean,
val localVersion: Long, // bumped on every local change
val uploadedVersion: Long, // equals localVersion when the backend is current
)
installId is the stable key. Tokens rotate, so the backend upserts by installId and replaces the token. Without it, every rotation creates a duplicate device row and you push twice.
PUT /v1/devices/{installId} { platform, token, userId, enabled, sdkVersion, locale, tz }
DELETE /v1/devices/{installId}
POST /v1/events [ { messageId, type: delivered|opened, ts } ]
GET /v1/inbox?since=<cursor> (the sync path)
4. Hard part one: the token lifecycle
A token is not an identity. It changes under you and can be revoked without notice.
Android (FCM). Firebase says the registration token may change after a restore on a new device, an uninstall and reinstall, or cleared app data. Override onNewToken and send the new token to your server; call getToken() when you need the current one. FCM treats a registration unseen for over a month as stale, and expires Android registrations after 270 days of inactivity. Store a timestamp with each token and refresh it on every upload.
iOS (APNs). Apple says to register on every launch and not to cache the token, because it changes after a restore from backup, an install on a new device or an OS reinstall. The token arrives in application(_:didRegisterForRemoteNotificationsWithDeviceToken:). The backend must hold many tokens per user.
The SDK flow:
- At startup, ask the platform for the current token.
- If it differs from the stored one, or the last upload is old, bump
localVersion. - A unique WorkManager job (business-oriented work that survives process death; an outbox drain on iOS) calls
PUT /v1/devices/{installId}whilelocalVersionis ahead ofuploadedVersion, then records the version it sent, not the latest. Retry on IOException. - Provider feedback closes the loop on the server. FCM returns
UNREGISTERED(404) for an invalid registration;INVALID_ARGUMENT(400) can also mean a bad payload, so delete only when the payload is known to be valid. APNs returns 410Unregisteredwith a timestamp in the body, and 400BadDeviceTokenfor an invalid token or an environment mismatch. For 410, compare the timestamp with the token's last registration time so a fresh re-registration is not removed. - A scheduled server job removes tokens not refreshed within your staleness window. Firebase's sample uses 30 days.
Logout is where most real bugs live. After user A logs out and B logs in, A's content must never reach B. On logout:
- Set
userId = nulland bump the version first; enqueue the unbind immediately. - Delete the platform token so a stale push cannot arrive (FCM has a delete-token call; see the notes).
- Clear the local inbox and cached payloads; fetch a fresh token on the next login.
The unbind can fail offline, so the server also unbinds when a new user claims the same installId, and the client drops any push whose userId does not match the current binding.
5. Hard part two: permission and presentation
Android 13 (API 33) added the runtime permission POST_NOTIFICATIONS. If the app targets 13 or higher, it controls when the dialog appears; with lower targets, the dialog appears the first time an activity starts after a channel is created. Android advises waiting until the user has context. The FCM SDK (23.0.6 and later) declares it in the manifest, but the app still requests it at runtime.
iOS shows the prompt on the first requestAuthorization call and never again. Apple advises asking in context, not at first launch. Provisional authorization (.provisional) skips the prompt: notifications arrive quietly in Notification Center only, with Keep and Turn Off buttons, and the status stays .provisional until the user chooses.
SDK rules:
- Never prompt automatically. Expose
requestPermission()and let the host pick the moment, ideally after its own pre-prompt. - Re-read
permissionState()on every foreground. Users change it in Settings, sonotificationsEnabledmust follow, and the backend should stop high-priority sends to devices that cannot show them. FCM may downgrade high-priority messages that do not produce a visible notification. - Create Android channels at startup, not inside the message handler. Firebase warns that on 12L and lower, a channel first created in the background can lose that notification.
Channels versus categories. Android 8 and later requires every notification to belong to a channel. After creation you cannot change a channel's importance, sound or vibration in code; the user has the final say, and you can change only the name and description. Design channels around user intent (orders, promotions), so disliking one does not disable all. iOS has no per-channel importance. Categories (UNNotificationCategory) define action buttons, and each notification carries an interruption level: passive, active, timeSensitive or critical. Map one SDK importance enum onto both.
6. Payload design and app states
Message types. On Android, FCM displays a notification message itself when the app is in the background, while a data message always goes to your code. A mixed message in the background shows in the tray and delivers data through intent extras after a tap. Both types are limited to 4096 bytes. An SDK should prefer data messages, because it controls the channel, dedupe and tap target. The cost is that your process must get to run.
| Knob | FCM (Android) | APNs (iOS) |
|---|---|---|
| Urgency | normal or high; high tries to deliver immediately, even in Doze | apns-priority: 10 immediate, 5 power-aware, 1 low power |
| Lifetime | TTL 0 to 28 days; TTL 0 discards if not deliverable now | apns-expiration as a Unix time; 0 tries once, no storage |
| Replace | collapse_key; four distinct keys held per device | apns-collapse-id, max 64 bytes |
Collapse state where only the latest value matters, never items that each matter, like chat. FCM stores at most 100 non-collapsible messages per Android device; past that it discards them all and calls onDeletedMessages(), which should trigger a full sync. Use a short TTL on time-bound content.
| State | Android | iOS |
|---|---|---|
| Foreground | onMessageReceived gets everything; the SDK decides on a banner | willPresent chooses the presentation options |
| Background | Data message reaches onMessageReceived; notification message goes to the tray | System shows alert pushes; content-available pushes are best effort |
| Killed | Data messages still start the service if FCM can; tap launches the launcher activity | Alerts show; a held background push is discarded on force-quit |
Apple documents background pushes as low priority, not guaranteed and possibly throttled (two or three per hour advised). Hence: push is a hint. Send {messageId, type, entityId} and let the client fetch content from GET /inbox?since=cursor. A cursor sync (pull-based, in offline-first terms) heals any gap. On iOS, send a visible alert with mutable-content: 1 so a Notification Service Extension can fetch or decrypt within about 30 seconds; if it runs out, the system shows the original content, so make that text acceptable.
7. Taps, tracking and coexistence
Routing a tap. Cold and warm starts must reach the same destination. On Android, put a PendingIntent for the destination activity in setContentIntent(). Apps targeting Android 12 (API 31) or higher cannot start an activity from a service or broadcast receiver that runs after a tap (a trampoline), so a "tap handler service" is blocked. Pass the deep link and messageId as extras and let the activity report the open. On iOS, UNUserNotificationCenterDelegate.didReceive handles the tap, and the delegate must be set before launch finishes, in willFinishLaunchingWithOptions or didFinishLaunchingWithOptions, or you can miss the notification that launched the app. The SDK hands the parsed link to the host through PushListener and never navigates itself.
Tracking with dedupe. Every push carries a messageId. The SDK records delivered when the handler runs and opened on tap, keeps a table of seen IDs, and drops repeats (the same message can arrive by push and by sync). Queue events as queued writes and batch them, with messageId plus type as the idempotency key. delivered means "reached our handler", not "seen".
Multi-SDK coexistence. Several libraries each want a FirebaseMessagingService, and you cannot count on yours being the one invoked. So ship no service. The host declares one, calls PushSdk.onToken and PushSdk.handleMessage, and passes anything the SDK returns false for (it checks a namespaced payload key) to the next library. On iOS the shared resource is the single notification center delegate, so the SDK provides a proxy that forwards to the host's delegate.
// Sketch: delegate proxy. `next` is the host's delegate; assign the proxy before launch finishes.
final class PushDelegateProxy: NSObject, UNUserNotificationCenterDelegate {
var next: UNUserNotificationCenterDelegate?
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping @Sendable (UNNotificationPresentationOptions) -> Void) {
PushSdk.shared.recordDelivered(notification.request.content.userInfo)
if next?.userNotificationCenter?(center, willPresent: notification,
withCompletionHandler: completionHandler) == nil {
completionHandler([.banner, .sound])
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping @Sendable () -> Void) {
PushSdk.shared.recordOpened(response.notification.request.content.userInfo)
if next?.userNotificationCenter?(center, didReceive: response,
withCompletionHandler: completionHandler) == nil {
completionHandler()
}
}
}
Opt-out, topics, throttling, quiet hours. Topic subscriptions are server state keyed by installId, mirrored locally, and changes ride the same reconciliation worker. Opt-out is a version bump the server checks at send time. Frequency caps and quiet hours belong on the server, which sees everything sent to the user; the client can only hold banners during local quiet hours.
8. Failure modes, testing, versioning, privacy
| Scenario | Handling |
|---|---|
| Token rotates while offline | Version bump persists; upload retries when the network returns |
| Logout with no network | Local binding cleared at once; server unbinds on the next claim of the installId |
| Push arrives for a logged-out user | Drop it when userId mismatches; never render |
UNREGISTERED or APNs 410 | Server deletes the token; the client re-registers on next launch |
| Over 100 stored messages (Android) | onDeletedMessages() triggers a full inbox sync |
| User disables notifications in Settings | Foreground check updates notificationsEnabled; server stops high-priority sends |
| Same message twice | messageId dedupe; one event recorded |
Testing. Hide the platform behind thin interfaces (TokenProvider, DeviceApi) and test with fakes rather than mocks, so the registry state machine runs in plain unit tests: token change, login, logout, offline retry. Use TestListenableWorkerBuilder for the worker. A library cannot own the host's WorkManager configuration, so workers take dependencies from the SDK's internal graph instead of @HiltWorker.
Versioning. Keep internals internal, run binary-compatibility validation in CI, and never rename payload keys, only add. The backend must tolerate old SDK versions for years, because app updates are slow. Send sdkVersion on every upload to see the long tail.
Privacy. Store only installId, token, user id, locale and time zone. Never log payloads. Logout deletes the device row.
9. How to build a push notification SDK on iOS (Swift)
| Concern | Android | iOS |
|---|---|---|
| Token source | onNewToken, getToken() | registerForRemoteNotifications() each launch |
| Invalidation | UNREGISTERED, 270-day expiry | 410 Unregistered, 400 BadDeviceToken |
| Permission | POST_NOTIFICATIONS (API 33+) | requestAuthorization, provisional option |
| User control | Channels | Per-app settings; categories define actions |
| Urgency | Priority plus channel importance | interruption-level plus apns-priority |
| Collapse | collapse_key | apns-collapse-id, thread-id to group |
| Rich content | Built in the app process | Notification Service Extension (separate process) |
| Tap | PendingIntent to an activity | didReceive on the delegate |
| Durable retry | WorkManager | None; drain your own queue on launch, foreground and push |
What is genuinely different:
- The extension is a separate target that the host must add, with its own time and memory limit. It cannot share live objects with the app, so keep it tiny and share config through an App Group.
- Silent pushes cannot carry correctness. Use
content-availableonly on top of sync-on-foreground. - Tokens are per environment. A sandbox token sent to production fails as
BadDeviceToken, so record the environment on upload.
10. What changes by level
- Mid-level: get the token, upload on change, ask for permission, deep link on tap, and know push may not arrive.
- Senior: the registration state machine with idempotent upsert by
installId, logout semantics, provider feedback and stale-token cleanup, data versus notification messages, collapse and TTL, trampoline limits, and the host-owned service for coexistence. - Staff: API stability across years of app versions, delivery observability and its blind spots, cross-sender rate limits on the server, consent, remote kill switches, and how teams extend the SDK without forking it.
Staff answer: the decision and the rollout
Decision: ship a single SDK with a server-controlled kill switch and a registration protocol that never breaks, rather than per-app integrations. Alternative: each team wires FCM and APNs directly, leaving hundreds of app versions with divergent token handling. Revisit trigger: if most traffic comes from versions that predate the kill switch, invest in server-side compatibility before new features.
Version skew: the server must treat every SDK version since the first as live. Add fields, never repurpose them, and have the server tolerate unknown or missing ones.
What to measure: active installs per SDK version, token-registration success, and delivery and tap rates by version, remembering that delivery is not directly observable. FCM keeps an undelivered message up to four weeks by default, so a late tap is not necessarily a bug. Guardrail: the kill switch must be exercised in a drill before it is needed, and a new SDK version must not lower registration success against the previous one.
Rollback on mobile: the kill switch stops sending or processing for clients that already fetched the config. It cannot fix a crash at startup, and a store rollout, Play staged or App Store phased, can be paused but not undone on devices that installed it, so the fix is a new build.
Opinion vs verified: FCM lifespan was read from Firebase docs, and the Play and App Store rollout facts from their help pages; the design above and the drill requirement are my opinion. See the staff mobile loop guide.
11. Follow-up questions to expect
- How do you add reply actions? (Categories on iOS,
RemoteInputon Android; the action must reach the backend while the app is not running.) - How do you measure delivery? (Sent, handled, displayed, opened; say which gaps are unobservable.)
- How do you roll out a new payload format? (Additive keys, capability flags on upload, server picks the format per
sdkVersion.)
Further reading (official docs)
- Best practices for FCM registration token management (Firebase)
- Receive messages in an Android app (Firebase)
- Notification and data message types (Firebase)
- Setting the lifespan of a message (Firebase)
- Collapsible and non-collapsible messages (Firebase)
- Android data layer
- Build an offline-first app (Android)
- WorkManager (Android)
- Notification runtime permission (Android)
- Create and manage notification channels (Android)
- Behavior changes in Android 12, notification trampolines
- Registering your app with APNs (Apple)
- Sending notification requests to APNs (Apple)
- Asking permission to use notifications (Apple)
- UNUserNotificationCenterDelegate (Apple)
- Modifying content in newly delivered notifications (Apple)
Verification notes (last verified 13 Oct 2026)
Android architecture audit on 2026-10-13 against the official architecture, offline-first, WorkManager and FCM pages. Remaining deviations: the SDK uses its own internal graph instead of Hilt (it is a library); PushListener is a host callback, not a ViewModel event; onNewToken and handleMessage are non-suspend; deleteToken semantics and whether getToken() is deprecated are unverified. Firebase, Android and Apple claims were verified against fetched docs. Unverified: how Android resolves several FirebaseMessagingService classes sharing one intent filter (the design avoids it), the Time Sensitive entitlement, RemoteInput and App Group details, and the Swift sketch under Swift 6 strict concurrency. The Android and iOS sections have not yet been reviewed by platform specialists. Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), firebase-messaging 25.1.3, kotlinx.coroutines 1.10.2, with stubs only for app-defined placeholders. The public-surface sketch was checked as declarations with stub bodies. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders. Completion handlers are marked @Sendable to avoid Swift 6 warnings. Added a staff-answer section (section 10) on 2026-10-13; verified the FCM message lifespan page, the Play staged-rollout help page and the App Store Connect phased-release page; the kill-switch and version-skew design are opinion.
Keep reading
Follow new case studies
Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.
New case studies appear in the feed first. Any RSS reader works.