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. And there's the product problem — what to send, when to ask, where a tap should land — which is where most apps actually fail. Here's the practical version of both.
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: on Android FCM is native; on iOS, FCM wraps APNs — Apple still delivers the notification, FCM just gives you one API and one token format in front of it.
The iOS side has non-negotiable setup: an APNs key uploaded to Firebase, push capability enabled in Xcode, and testing on a physical device — the iOS simulator historically didn't support remote push (recent simulator versions on Apple Silicon can, with caveats), so a real device is still the only trustworthy test.
Tokens are identity — treat them that way
Each install gets a device token. Your backend needs it to send anything, so on startup: fetch the token, associate it with the signed-in user server-side, and listen to onTokenRefresh — tokens rotate, and a stale token means silently undelivered pushes.
Two rules save real pain later. Store multiple tokens per user, keyed by device, or your user's phone stops buzzing the day they sign in on a tablet — and remove the token on sign-out, or the phone's next owner of that account session keeps getting someone else's notifications. And when your provider tells you a token is invalid, delete it; sending to dead tokens forever is how notification systems rot.
The three app states
Delivery behaves differently depending on where your app is, and you have to handle all three:
- Foreground: no system banner appears. You get a callback (
FirebaseMessaging.onMessage) and decide what to do — often an in-app banner, or nothing if the user is already looking at the relevant screen. Suppressing a "new message" popup for the chat the user is currently reading is the difference between polished and annoying. - Background: the system shows the notification;
onMessageOpenedAppfires when the user taps it. Your job is routing (see deep links below). - Terminated: the tap launches the app, and you retrieve the trigger via
getInitialMessage()during startup. Forgetting this case is the classic bug: notifications work in testing (app always running) and dump cold-start users on the home screen in production.
Also know the difference between notification messages (system displays them automatically) and data messages (silent payload, your code decides). Data messages give you control but are subject to OS throttling — battery savers and background restrictions mean silent pushes are not a reliable realtime channel. Anything the user must see should be a notification message.
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, at that comment — not the app's home screen. Put a route or entity ID in the payload, and route from all three app states through one function so the behavior can't drift apart.
The product side: permission and restraint
On iOS (and Android 13+), you must ask permission — and the OS gives you essentially one clean shot. Don't burn it at first launch, before the app has shown any value. Ask in context, when the user does something notifications obviously improve: follows a topic, places an order, starts a conversation. Acceptance is dramatically better when the benefit is self-evident at the moment of asking.
Then respect the grant. Every notification trains users on your worth. Transactional and personal ones (your order shipped, someone replied) sustain trust; generic re-engagement blasts ("We miss you!") train users to disable you — and the OS increasingly notices too. Offer per-category toggles in settings so 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.
The takeaway
Get the plumbing right once — FCM, token lifecycle tied to auth, all three app states, deep links from a single router — and then be disciplined about what you send. Ask for permission at a moment of obvious value, make every notification specific and personal, and give users granular controls. Push is the most intimate channel you have; treat it like you'd like your own phone treated.