Push Notifications in Flutter, Done Right
Push notifications are two problems wearing one name. There's the plumbing — tokens, platform setup, delivery states — which is fiddly but finite: get it right once and it stays right. And there's the product problem — what to send, when to ask, where a tap should land — which is open-ended and where most apps actually fail. Plenty of apps with flawless FCM integrations have trained their users to swipe every notification away unread; the engineering worked, the product didn't. Here's the practical version of both halves.
The plumbing: FCM as the backbone
For Flutter, the standard path is Firebase Cloud Messaging (FCM) via the firebase_messaging package. One integration covers both platforms, but it's worth understanding what that actually means: on Android, FCM is the native push system. On iOS, nothing delivers pushes except Apple's APNs — FCM wraps it, giving you one API and one token format, while Apple still does the delivery. This matters when debugging: an iOS delivery problem can live in the FCM-to-APNs handoff, which is why the APNs configuration step is not optional ceremony.
The iOS side has non-negotiable setup: an APNs authentication key generated in your Apple developer account and uploaded to Firebase, the push capability enabled in Xcode, and testing on a physical device — the iOS simulator historically didn't support remote push at all (recent simulator versions on Apple Silicon can, with caveats), so a real device remains the only test you should trust. Budget an afternoon for this checklist the first time; every skipped step surfaces later as "notifications just don't arrive on iOS" with nothing in any log.
Tokens are identity — treat them that way
Each install gets a device token — an opaque string that is, functionally, this app-install's postal address. Your backend needs it to send anything, so on startup: fetch the token, send it to your server, associate it with the signed-in user, and listen to onTokenRefresh — tokens rotate at the platform's discretion, and a missed rotation means silently undelivered pushes to a user who did nothing wrong.
Three rules save real pain later:
- Store multiple tokens per user, keyed by device. Users own phones and tablets; a users-table column named
fcm_token(singular) means the phone goes silent the day they sign in on the iPad. - Delete the token on sign-out. Otherwise the device keeps receiving the previous account's notifications — which on a shared or resold device is a genuine privacy incident, not a cosmetic bug.
- Prune tokens the provider rejects. When FCM reports a token invalid, delete it. Notification systems rot from dead tokens: send volume grows, delivery rates sink, and eventually someone "fixes" it by sending more.
The three app states
Delivery behaves differently depending on where your app is, and you have to handle all three — this is the part of push that's most under-tested, because development mostly exercises one state:
- Foreground: no system banner appears — the OS assumes the app will handle it. You get a callback (
FirebaseMessaging.onMessage) and decide: often a lightweight in-app banner, or nothing if the user is already looking at the relevant screen. Suppressing the "new message" popup for the chat the user is currently reading is the difference between polished and annoying. - Background: the system displays the notification;
onMessageOpenedAppfires when the user taps it. Your job is routing — see below. - Terminated: the tap launches the app, and you retrieve what triggered it via
getInitialMessage()during startup. Forgetting this case is the classic push bug: notifications work perfectly in testing (where the app is always running) and dump cold-start users on the home screen in production (where it usually isn't). Like deep links, the pending intent has to survive your app's initialization — buffer it, finish booting, then navigate.
Also know the difference between notification messages (the system displays them automatically) and data messages (silent payload; your code decides what happens). Data messages give you control, but they're subject to aggressive OS throttling — battery savers, background restrictions, and vendor-specific process killers mean silent pushes are not a reliable realtime channel, especially on Android's more enthusiastic battery-managing brands. Anything the user must actually see should be a notification message; anything critical should also be verifiable by the app when it next opens, so a swallowed push degrades to "slightly late" instead of "lost."
A tap is a promise
Every notification should deep-link to the exact content it announces. "New comment on your post" must open that post, scrolled to that comment — not the app's home screen with the user left to hunt. Put a route or entity ID in the payload, validate it on arrival (the content may have been deleted since the push was sent), and route from all three app states through one function so behavior can't drift between them.
This is the same single-router discipline as deep linking, and the two systems should literally share the code: a notification tap is just a deep link that arrived via APNs instead of a browser. Build it once, debug it once.
The product side: permission and restraint
On iOS — and Android 13+, which adopted the same model — you must ask permission, and you get essentially one clean shot: once a user declines, re-asking requires them to dig through system settings, which effectively means never. Don't spend that shot at first launch, before the app has demonstrated any value, wedged between three other permission dialogs. Ask in context, at the moment the user does something notifications obviously improve: follows a topic, places an order, starts a conversation. "Want to know when someone replies?" at the moment of posting is an easy yes; a cold prompt at first launch is an easy no, and the acceptance-rate gap between the two is dramatic.
A pre-permission screen — one line of your own UI explaining what notifications you'll send, then the system dialog — costs one tap and buys real signal: users who decline your screen never see the system prompt, so the one-shot is preserved.
Then respect the grant, because every notification you send trains users on what your app is worth. Transactional and personal ones (your order shipped, someone replied, your export finished) sustain trust. Generic re-engagement blasts ("We miss you! 🎉") train users to disable you — and the OS increasingly notices too: both platforms now surface "this app sends notifications you ignore" style management prompts, so spam literally invites the system to offer your removal. Send less than you're tempted to. Nobody has ever uninstalled an app for notifying too little.
Finally, offer per-category toggles in your settings — order updates, replies, tips, each switchable. A user irritated by one stream can mute it without nuking everything at the system level. A partial opt-out you designed beats the total one the OS offers; it's the difference between losing a channel and losing the whole voice.
The takeaway
Get the plumbing right once — FCM over APNs configured completely, token lifecycle tied to your auth (multiple per user, deleted on sign-out, pruned when dead), all three app states handled, every tap deep-linking through a single router. Then be disciplined about the product: ask for permission at a moment of obvious value, make every notification specific and personal, and give users granular controls before the OS gives them the nuclear one. Push is the most intimate channel you have — it buzzes in pockets and lights up locked screens. Treat it the way you'd want your own phone treated, and it stays open to you for years.
Further reading
- Firebase Cloud Messaging docs — architecture, message types, and platform setup.
- firebase_messaging — the Flutter plugin, with the three-state handling in its README.
- Apple: APNs overview — what's actually delivering your iOS pushes.
- Deep links that actually work — the routing layer every notification tap should share.