Push notifications demo well. You wire up a token, send a test from a dashboard, it arrives on the device, and the feature looks finished. What is actually finished is the easy half. The part that breaks is everything that happens after launch: tokens that rotate, permissions the user never granted, silent notifications the operating system quietly stops delivering, and a tap that opens the wrong screen because the app was cold when it arrived.
The framing that avoids most of this is to treat push as an unreliable transport for a hint, never as a data channel. A notification may arrive late, arrive twice, or not arrive at all, and none of those are bugs you can fix. Any design where the notification carries the only copy of something important is a design that will lose data.
A device token is not stable. It changes when the app is reinstalled, when data is restored to a new device, occasionally after an OS upgrade, and on Android when Play Services decides to rotate it. A backend that stores one token per user and never revisits it will, within months, be sending a meaningful share of its traffic into the void.
Store tokens per device rather than per user, with a last-seen timestamp, and refresh on every launch rather than only on first permission grant. When the provider reports a token as unregistered — and both APNs and FCM will tell you — delete it rather than retrying. Retrying a dead token costs quota and hides the fact that your reachable audience is smaller than your user count, which is a number worth knowing accurately before you plan anything around it.
The case that causes real incidents is a shared device. A user logs out and a colleague logs in, and if the token is still associated with the first account, the second person now receives the first person's notifications, with whatever is in the body text. Unregister the token on logout, server-side, as part of the logout transaction rather than as a best-effort call afterwards. This is a data-protection problem, not a polish problem.
The permission prompt can be shown once. If the user declines, you cannot ask again — you can only send them to system settings, which almost nobody does. An app that prompts on first launch is asking before it has given the user any reason to say yes, and it converts a recoverable no into a permanent one.
Ask at the first moment the notification has obvious value: after a booking is confirmed, when a reminder is being set, at the point the user has opted into something that needs a notification to work. Explain what will be sent before the system dialog appears, so the dialog is a confirmation rather than a surprise. On iOS, provisional authorization is worth knowing about — it lets notifications arrive quietly in the notification centre without a prompt, which gives the user something concrete to judge before you ask for full permission.
Using a silent push to trigger a background fetch is a standard pattern and it is throttled far more aggressively than most teams expect. iOS treats content-available notifications as budgeted rather than guaranteed, weighing how often the user opens the app, battery state and low-power mode; an app that is rarely opened may see delivery rates that make the mechanism useless. Android's behaviour varies by manufacturer in ways stock testing will not reveal.
This is the same conclusion as background sync, arrived at from a different direction: silent push is an optimisation that reduces latency when it works, and the app must be correct when it does not. Fetch on foreground regardless. If a silent notification arrives and saves you a round trip, good — but nothing should depend on it having arrived.
A user who has been offline for a day comes back to whatever accumulated. Without collapsing, that is fourteen separate notifications about the same conversation, and the rational user response is to disable notifications for your app entirely — a decision they will not revisit.
Both platforms support replacing a pending notification rather than stacking it: a collapse ID on APNs, a collapse key on FCM. Use one per logical subject, so the user sees the current state of a thing rather than its history. Where a summary genuinely is the right output, compute it server-side and send one notification rather than sending many and hoping the operating system groups them sensibly.
Tapping a notification has two quite different code paths. If the app is in memory, your handler fires with the payload and the router is already mounted. If the app was not running, the process starts, the payload is waiting, and your navigation stack may not exist yet — so a naive handler navigates into a router that is not ready and the user lands on the home screen with no explanation.
Hold the pending payload and resolve it once navigation has mounted, rather than handling it wherever the notification callback happens to fire. Then check authentication: a notification about a record the user can no longer access, or that arrived while they were logged out, must route somewhere sensible rather than into an error. Both cases are easy to reason about and easy to forget, and both are guaranteed to happen.
APNs allows a four-kilobyte payload and FCM the same order of magnitude, which sounds generous until someone tries to send a record rather than a reference. Keep the payload to identifiers and let the app fetch the detail when it opens. This is the same conclusion as treating push as a hint rather than a channel, arrived at from the direction of a hard limit rather than a reliability argument.
The more consequential constraint is who can read it. A notification body renders on a locked device, visible to anyone holding the phone and to anyone standing near it. "Your test results are ready" is fine. The result itself is not. The same applies to names, amounts and anything that identifies a third party — a loyalty balance or a client name in a body text is a disclosure you did not intend to make. Decide per notification type what is safe on a lock screen, and keep the sensitive half behind the app's own authentication.
Rich notifications — an image, a custom layout, content decrypted on arrival — need a notification service extension on iOS, which is a separate native target with its own bundle identifier, its own provisioning and a tight execution budget. It is native work on both platforms, and it belongs in the estimate as native work rather than as a formatting change.
APNs runs separate sandbox and production environments, and a token minted against one is meaningless to the other. A debug build produces a sandbox token; TestFlight and App Store builds produce production tokens. The failure is not loud — the provider accepts the request and the notification simply never arrives — and it reliably costs somebody an afternoon at least once per project.
Record the environment alongside every token you store, and make the backend refuse to send a sandbox token through the production endpoint rather than letting it fail silently. The same discipline applies to keys: a staging server holding production credentials will eventually send a test notification to every real user, which is the kind of mistake that is trivial to prevent and impossible to retract.
The provider accepting your request means the request was well-formed. It says nothing about whether the notification reached the device, was displayed, or was seen. Dashboards that report sends as a success metric are measuring your own outbound traffic, which is rarely the question anyone is actually asking.
If delivery matters, measure the other end: have the app report when a notification is received and when it is opened, and compare that against what you sent. The gap is usually larger than expected, and it is the number that tells you whether a notification-driven feature is working. If that gap is where your product's reliability lives, the honest conclusion is that the feature needed a durable channel — an inbox the app reads on launch — with push as the thing that makes it timely rather than the thing that makes it work.
We design and build AI-powered platforms, web applications, and mobile products. Tell us what you are working on.
Get in touch