Most apps lose push permission within the first week, and once it is gone almost nobody turns it back on. The cause is rarely the delivery code. It is asking too early and sending too much. This tutorial covers both halves of push notifications best practices: the permission and preference design, and the delivery plumbing through Firebase Cloud Messaging (FCM) and Apple Push Notification service (APNs).
Prerequisites
- A mobile app in Flutter, React Native or native code with a back end you control.
- A Firebase project with Cloud Messaging enabled, and an APNs key uploaded to it so FCM can deliver to iOS.
- A database table for device tokens with columns for user id, token, platform, app version and last seen.
Step 1: ask at the right moment
Never request permission on first launch. The system prompt can be shown once; if the user says no, you are done until they visit settings. Instead, trigger the request immediately after an action that makes a notification obviously valuable: placing an order, starting a chat, setting a reminder. Show your own one-line explanation first, then the system prompt.
Pseudo-flow, in plain words: user completes action; app shows an in-app card “Get a message when your order ships?” with Yes and Not now; on Yes, call the platform permission request; on Not now, set a flag and do not ask again for 14 days.
The platform call itself is one line. In Flutter with firebase_messaging it is FirebaseMessaging.instance.requestPermission(). In React Native Firebase it is messaging().requestPermission(). On Android 13 and later, notifications need the POST_NOTIFICATIONS runtime permission, so the same timing rule applies there too. On older Android versions, permission is granted at install, which makes per-category preferences more important.
Step 2: register and store the token
On every app start, after permission is granted, fetch the current token and send it to your back end. Tokens change on reinstall, restore from backup and sometimes after OS updates, so treat the fetch as idempotent: insert if new, update last seen if known.
Also subscribe to token refresh while the app runs. In Flutter that is FirebaseMessaging.instance.onTokenRefresh, a stream you listen to and post each new token from. Missing it means a rotated token goes unrecorded until the next cold start.
Back end, as a plain SQL idea: insert into device_tokens (user_id, token, platform, app_version, last_seen) values (?, ?, ?, ?, now()) on conflict (token) do update set user_id = excluded.user_id, last_seen = now(). One token belongs to one device; a user may have several.
Step 3: categories, not a single switch
Define three to six notification categories that mean something to the user: order updates, messages, reminders, promotions. Store the user's preference per category on the server, default on for transactional categories and off for promotions. Every notification you send carries its category, and the send code checks the preference before sending. The in-app settings screen lists the categories with switches. This is the single most effective way to keep the operating-system-level switch on: users mute the category that annoys them instead of the whole app.
Step 4: send through FCM
Use the FCM HTTP v1 API from your server with a service account; the legacy server key API has been retired. A minimal message, written out as the JSON shape you post: message with token, notification containing title and body, data containing category and a deep link, and platform blocks for android (priority) and apns (headers with apns-priority and a payload with sound and a badge). Keep the payload under four kilobytes.
The endpoint is POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send, with an OAuth 2.0 bearer token from the service account. The official Admin SDKs handle token minting and refresh for you, so use them rather than hand-rolling auth.
Send in batches with a queue, not inline in a web request. A notification for a thousand users is a background job that reads tokens in pages of 500 and records the result per token.
Step 5: handle the responses
FCM tells you when a token is dead. On an UNREGISTERED or NOT_FOUND error for a token, delete that row immediately. On a rate-limit or unavailable error, retry with backoff. Ignoring these responses is how apps end up sending to millions of dead tokens and getting throttled.
Step 6: deep links and foreground handling
Every notification carries a deep link in its data block, and tapping it opens that screen, not the home screen. In the foreground, the app receives the message directly; decide per category whether to show an in-app banner or stay silent. Order updates usually show; a new message in the chat the user is already reading does not.
Push notifications best practices: two mistakes that cause bugs
Sending from the web request. The checkout completes, the code calls FCM inline, FCM is slow, the checkout times out. Always queue.
One token per user. A user with a phone and a tablet gets notifications on only one, and when they reinstall, the stale token keeps getting messages until FCM gives up. One row per device, keyed by token.
How to test it
- Real devices on both platforms. Simulators do not receive push on iOS.
- Three app states: foreground, background and killed. Each takes a different code path.
- Permission denied, then granted in settings: the app must pick up the change without a reinstall.
- A deliberately invalid token in the database: confirm it is deleted after one send.
- Category muted: confirm the send is skipped on the server, not just hidden on the device.
We build notification systems like this for clients as part of most mobile projects. If you have one that is getting muted, ask us about yours.
Frequently asked questions
Can we skip FCM and talk to APNs directly?
You can, and large apps do. For most teams FCM's single API for both platforms is worth the extra hop.
How often is too often?
Transactional messages are never too often. Anything promotional more than twice a week is where mute rates climb sharply in our experience.
Why do some notifications never arrive on Android?
Usually battery optimization on certain manufacturers' builds, or a normal-priority message deferred in Doze mode. Use high priority only for messages the user needs immediately. Overusing it can lead Android to deprioritize your app's messages.



