It's a one-line item on the roadmap. "Send a push notification when X happens." Estimate is two days, three if the backend doesn't have FCM credentials yet. There's a library for it.
The library is the visible part. The other 90% is platform lifecycle, registration state machines, race conditions with navigation, payload archaeology, and a half-dozen iOS and Android quirks. Nobody writes them down. You learn them after you ship, when the bug reports start coming in.
I built this stack with custom native modules, wrapping APNs on iOS and FCM on Android directly, instead of reaching for Notifee, React Native Firebase, or OneSignal. The trade was the obvious one. I gave up the abstraction the libraries provide and got control over every edge case in return. The decision wasn't ideological. The failure modes I cared about were already filed against those libraries, unfixed.
This post is what's underneath. Not the library you import. The work the library is hiding from you, or trying to.
The waterline: registration is a state machine, not a function call
The tutorial version is one line:
const token = await messaging().getToken()
sendToBackend(token)
What's actually happening is a few rounds of back-and-forth between the OS, your app, and the push provider. At least three places it can quietly stop working.
On iOS, registration is split across delegate methods. The token comes back as NSData, not a string, and you hex-encode it before anyone outside the AppDelegate gets to see it:
- (void)application:(UIApplication *)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
const unsigned char *bytes = deviceToken.bytes;
NSMutableString *hex = [NSMutableString stringWithCapacity:(deviceToken.length * 2)];
for (NSUInteger i = 0; i < deviceToken.length; ++i) {
[hex appendFormat:@"%02x", bytes[i]];
}
[[NSNotificationCenter defaultCenter]
postNotificationName:@"DeviceTokenRegistered"
object:nil
userInfo:@{ @"token": hex }];
}
That's the documented part. Three things aren't.
iOS 18+ silently drops the first registration call if you make it immediately after the user grants permission. No error, no callback, nothing. A 1.5 to 2 second delay and one retry recovers from it almost every time:
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
if (![UIApplication.sharedApplication isRegisteredForRemoteNotifications]) {
[UIApplication.sharedApplication registerForRemoteNotifications];
}
});
This isn't in any Apple doc. I found it after enough TestFlight users on iOS 18 reported notifications "just not working."
Tokens change. APNs rotates them on app reinstall, restore-from-backup, or migration to a new device. FCM rotates on its own schedule. If you don't dedupe registrations, you re-register the same device on every cold start and your backend's device tables grow forever.
Token callbacks outlive their context. A token refresh that fired pre-logout can land post-logout, and re-bind the device to the previous user. The cheap fix: hold a lastRegisteredToken ref and short-circuit any callback whose token matches what you just sent.
That's three places one line of pseudocode is hiding.
Permissions are three different things
iOS, Android pre-13, Android 13+. Each one is a different model, and you handle all three in one codebase.
iOS is the cleanest. Request Alert | Sound | Badge once, the OS gives you a yes/no. There are extras most apps never use: provisional authorization (notifications go straight to the notification center), critical alerts (bypass Do Not Disturb), time-sensitive alerts on iOS 15+. The core flow is one async call. Catch: if the user denies, you can never re-prompt. They go to Settings or nothing.
Android pre-13 has no runtime permission for notifications at all. As long as the user installed your app, you can post. But on Android 8+ you have to create a notification channel first or the notification is silently dropped. The channel is also where importance, sound, and vibration live, not the notification itself:
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"General",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "App notifications"
enableVibration(true)
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
IMPORTANCE_HIGH is what enables heads-up banners. IMPORTANCE_DEFAULT puts the notification in the tray with no popup. The user can override your importance from settings, and you have no recourse.
Android 13+ added POST_NOTIFICATIONS as a runtime permission. You declare it in the manifest, request it at runtime, and check the SDK level before requesting because the API doesn't exist below 33. Like iOS, a denied permission has to be re-granted from system settings.
The branch you actually write:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(context, POST_NOTIFICATIONS) != GRANTED) {
ActivityCompat.requestPermissions(activity, arrayOf(POST_NOTIFICATIONS), 1)
}
}
ensureChannel(context)
Three platforms, three permission models, one codebase. None of this is in the tutorial.
Three app states, three completely different code paths
This is the centerpiece of the iceberg. The same notification has to be handled three ways, depending on what the app is doing when it arrives.
| State | iOS callback | Android callback | OS shows banner? | App can react before tap? |
|---|---|---|---|---|
| Foreground | willPresentNotification: |
onMessageReceived (FCM service) |
No (you choose) | Yes |
| Background |
didReceiveNotificationResponse: (on tap) |
onMessageReceived + tap intent |
Yes | No |
| Quit / killed | launchOptions[...RemoteNotificationKey] |
Intent extra in MainActivity
|
Yes | No |
Each row hides something.
Foreground. iOS does not show a system banner when the app is open. You decide. The decision is one option mask returned from willPresentNotification::
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))handler {
NSDictionary *aps = notification.request.content.userInfo[@"aps"];
BOOL silent = [aps[@"content-available"] intValue] == 1 && !aps[@"alert"];
handler(silent
? UNNotificationPresentationOptionNone
: UNNotificationPresentationOptionAlert
| UNNotificationPresentationOptionSound
| UNNotificationPresentationOptionBadge);
}
Skip this delegate method and foreground notifications vanish. You'll spend a week wondering why testing-on-device fails to reproduce what users are seeing.
Android has no equivalent. The FCM service runs on every notification regardless of state, so you detect foreground yourself:
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
val isForeground = info.importance == IMPORTANCE_FOREGROUND
Foreground: suppress the system notification, let JS render its own banner. Background: build the system notification and post it.
Background. The OS shows the banner. Your code runs only when the user taps. iOS hands you the payload via didReceiveNotificationResponse:. Android hands you the intent in MainActivity.onCreate or onNewIntent.
Quit. The app process is dead. The OS shows the banner. On tap, the app cold-starts, and your notification handler doesn't exist yet. JavaScript hasn't been loaded. The payload is delivered as a launch option (iOS) or an intent extra (Android), and you have to retrieve it after React mounts:
const initial = await Notifications.getInitialNotification()
if (initial) {
setRedirectTarget(parseNotification(initial, { bootingApp: true }))
}
On Android, the cleanest move is to write the payload to SharedPreferences in the FCM service when the app isn't running, then read it back when JS comes online. Skip this and the launch intent gets consumed by the OS before your code is alive to see it.
The mistake I made first was writing one handler. They look like the same event from JS. They are not. The cold-start payload on iOS is shaped differently from the foreground one. The Android intent doesn't look like the FCM message your foreground handler receives. We'll get to that.
The race nobody warns you about
A user taps a notification. The app was killed. The OS hands you the payload before:
- React has mounted
- Your navigation container is ready
- Auth state has hydrated from storage
- The initial data fetch has resolved
Call navigation.navigate(...) here and three things can happen, all bad. You crash. You no-op silently and the user is stuck on the splash screen. Or you navigate, but auth is empty. The screen renders unauthenticated and bounces them to login. Confusing, since they came in through a notification that implied they were already signed in.
The fix is a "deferred redirect" pattern. Notifications never navigate directly. They write a target into context state. A separate effect watches both the target and a readyToRedirect flag, and only fires when every precondition holds:
const readyToRedirect = useMemo(
() => isLoggedIn
&& !isFirstLogin
&& hasSeenOnboarding
&& !isHydrating,
[isLoggedIn, isFirstLogin, hasSeenOnboarding, isHydrating]
)
useEffect(() => {
if (readyToRedirect && redirectTarget) {
navigationRef.current?.dispatch(
CommonActions.reset({
index: 1,
routes: [{ name: 'Home' }, { name: 'Detail', params: redirectTarget }],
})
)
setRedirectTarget(null)
}
}, [readyToRedirect, redirectTarget])
The same pattern handles a more annoying case: the user is in the foreground, a notification comes in, they tap the in-app banner, but their session expired between the banner appearing and the tap. The redirect waits in state until auth re-hydrates, then fires. The tap doesn't get lost.
The corollary is about when you register listeners. Don't register on app startup. Register only when appStateVisible === 'active' && isLoggedIn. Otherwise the very first launch (fresh install, logged out, no auth state) fires a redirect to a screen the user can't open, and the navigator throws.
A "wait until ready" gate sounds trivial until you count how many things have to be ready. In production I count four: auth, navigation, hydrated storage, first paint. Miss any one and the bug only reproduces on cold starts triggered by notifications. Every notification.
The payload is not the payload
A notification on iOS arrives in three or four shapes depending on app state.
-
Foreground: top-level
title,body, custom data oncustomData -
Cold start (from
launchOptions):aps.alert.title,aps.alert.body, custom data still oncustomData -
Silent push:
aps.content-available: 1, no alert, custom data only
Android adds another twist. FCM only allows string-string maps in the data field, so any structured custom data is JSON-stringified by the sender. You parse it on the JS side, in a try/catch, with a logged warning on failure. Skip the try/catch and a single malformed payload from the backend silently breaks your entire notification handler.
The only sane move is a normalizer. One parseNotification that returns a single shape, and the rest of the app only ever sees that shape:
type ParsedNotification = {
type: 'redirect' | 'silent' | 'broadcast'
title: string
body: string
targetId?: string
url?: string
}
function parseNotification(raw: RawNotification, opts: ParseOpts): ParsedNotification {
if (opts.platform === 'android') {
const data = safeJSON(raw.customData) ?? {}
return { type: data.type ?? 'broadcast', title: raw.title,
body: raw.body, targetId: data.id, url: data.url }
}
if (opts.bootingApp) {
return { type: raw.aps.customData?.type ?? 'broadcast',
title: raw.aps.alert?.title ?? '',
body: raw.aps.alert?.body ?? '',
targetId: raw.aps.customData?.id }
}
return { type: raw.aps.customData?.type ?? 'broadcast',
title: raw.title, body: raw.body,
targetId: raw.aps.customData?.id }
}
Tests cover each variant: cold-start iOS, foreground iOS, Android with valid JSON, Android with malformed JSON. The malformed-JSON test is the one that catches the bug. Every time the backend ships a payload change without telling mobile, the test fails before it ships.
The bits you'll only learn by shipping
Things that aren't in any tutorial. I learned each of these from a bug report.
Notification Service Extensions have a 30-second budget. If you're modifying notifications in a Service Extension (decryption, downloading rich media, sending delivery receipts), the OS kills the extension and delivers the original payload if you overshoot. Set a 5-second timeout on any HTTP work the extension does. The user gets the notification either way, but you don't want a silently-truncated payload because the network was slow.
App icon badges are yours to manage. The OS doesn't decrement them when the user reads a notification. You set the count from the app, from the server, or both, and reset to zero when the relevant view mounts.
Delivery receipts are a fourth code path. If you want to know which notifications were actually delivered, you wire receipts in three places: the Service Extension fires "received" before the user sees the banner, the foreground/tap handler fires "read on tap," and silent push fires "received" again. None of these deduplicate. The backend has to.
Token refresh fires while you're logged out. FCM rotates on its own schedule. If your registration code doesn't check whether anyone's logged in, you'll re-bind the device to a stale or wrong user. Guard with a stored lastRegisteredFor and a "user is currently logged in" check.
Direct boot. If you want notifications to surface while the device is encrypted-and-locked (post-reboot, pre-unlock), your FCM service needs directBootAware="true" in the manifest, and you can only access protected storage. Most apps don't need this. The ones that do really need it.
Foreground listener leaks. Register on resume, unregister on background. Otherwise context updates fire after the relevant component unmounts, and you log a setState on unmounted component warning every time a notification arrives. The user sees nothing wrong. Your error logs fill up.
None of these are in the README. They become the README, after.
What I got back
What I have now is a push notification system that survives every state combination I've actually seen: cold start, mid-login, kill-and-relaunch, OS reboot, denied-then-granted permissions, foreground notification while a session is timing out. The path the user actually takes is rarely the one in the demo video.
A few concrete things came out of it:
- A
parseNotificationnormalizer with tests covering four payload shapes, runnable in plain Node, no native bridges required - A clean seam: native owns registration, foreground display, OS plumbing, intent retrieval. JS owns parsing, navigation, product logic. They don't drift because they speak through one event shape.
- A deferred-redirect pattern that handles cold-start, foreground, and re-auth races with one piece of code
And the cost. Every iOS major version, every Android API bump, I have to revisit this. The libraries would have absorbed some of it. I chose not to use them because the failure modes I cared about (iOS 18 registration, cold-start payload divergence, silent push receipts, the navigation race) were already filed against those libraries, unfixed. That's the trade I made. I'd make it again, knowing the maintenance cost up front.
Push notifications were a one-line item on the roadmap. The line item was true. It just wasn't the whole shape.
This article was originally published by DEV Community and written by Amanda Gama.
Read original article on DEV Community