Push Notifications on Flutter: Killing Duplicate Alerts Across iOS and Android
The Night We Shipped Three Notifications for One Message
Two weeks from the Sync beta at Supernova, a QA engineer dropped a video in the channel: one message to her, three buzzes on her iPhone. Three banners stacked, same text, same avatar. Her Android test device did something else: sometimes one alert, sometimes none, and once a notification four minutes after she had read the message.
Sync is a realtime super app: messaging, presence, voice and video. Push notifications are where my Flutter code meets two operating systems, two push services, and a delivery model I do not control. I had assumed the server sends a push and the phone shows it. Most of the work is on the device, after the push lands.
This is a client story. The server just sends a push; everything that went wrong lived on the phone.
APNS and FCM Do Not Speak the Same Language
A push notification is not one thing. On iOS the payload travels over APNS, on Android through FCM. Even with Firebase Messaging on both platforms, FCM just hands the iOS payload to APNS underneath, and the two have different rules.
On iOS the system decides whether to draw the banner from the aps block. My Flutter code cannot veto it while the app is backgrounded: if an alert is present, iOS shows it. On Android, FCM behaves differently depending on whether the app is in the foreground and how the message is shaped. Same Dart code, two execution paths.
Thinking in terms of APNS alerts and FCM data messages, not generic "push notifications," cleared up half my confusion.
Notification Messages Versus Data Messages
This distinction cost me the most time.
A notification message carries a notification block. The OS renders it when the app is backgrounded, and your Dart handler may never run. You get no control over grouping, dedup, or the notification ID.
A data message carries only a data block. The OS renders nothing: it wakes your app (within limits) and hands the payload to your code, and you build the notification yourself.
Our server was sending both a notification and a data block. On Android in the background, FCM rendered the notification block automatically, then my background isolate woke up, read the data block, and posted its own local notification. Two of the three alerts: the system drew one, I drew another on top.
The sending side switched to messages carrying only a data block, so the client is the only thing that draws anything. That is one sentence of server change; the work was making the client handle every state.
Three App States, Three Ways to Show an Alert
Flutter Messaging gives separate entry points for foreground, background, and terminated, each a code path that can decide on its own to show an alert.
In the foreground the OS shows nothing and calls my onMessage listener. Early on I always posted a local notification there, so users got banners for the conversation already on screen.
In the background, a top level function runs in a separate isolate with no access to my widget tree, my providers, or anything in memory. Whatever it reads must come from disk or the payload.
From terminated, the launching notification arrives through getInitialMessage, and you get it once. Read it late or twice and routing misfires.
So I funnel all three into one path that dedups and renders, instead of three copies of "show notification."
The Double Alert: Push Meets the Realtime Stream
This one is specific to apps with both push and a live socket.
In the foreground a new message arrives twice: down the realtime stream, which is how the chat updates live, and as a push, because the server does not know the socket is connected when it fans the message out. The app inserted the message into the conversation, then half a second later popped a banner for it. Two sources, one message.
Suppressing pushes in the foreground is the wrong fix: I do want a quiet alert when the message is for a different conversation than the one on screen. The fix is dedup by identity, so the realtime path and the push path have to agree on what a message is.
Deduplicating on the Device
Every message in Sync has an ID assigned by the server, and the same ID rides in the realtime frame and the push data payload. That shared ID is the trick.
I keep a small ring buffer of recently seen message IDs, persisted so the background isolate can read it. Before anything draws a notification it checks whether that ID was already rendered. When the realtime stream inserts a message it stamps the ID as handled, and the push that lands a moment later stays quiet.
For the notification itself, I derive the platform notification ID from the message ID as a deterministic integer instead of a random one. If the same message reaches the render step twice, the second post updates the first notification instead of stacking a duplicate. On iOS I use the message ID string as the notification identifier for the same reason.
One stable message ID flowing through every path collapsed three banners into one alert.
Tapping Through to the Right Screen
Dedup fixed the duplicates. The next complaint was taps landing on the wrong screen, or the home screen, especially from a cold start.
Routing from a notification tap has the same three states problem. If the app is alive, the tap fires a callback and I navigate immediately. If it was terminated, the tap launches the app and the message waits in getInitialMessage, but my navigator and auth state are not ready. I was navigating before the router existed, so the route was dropped and the user landed on the default screen.
The fix was to buffer the pending link and replay it once the app finished booting: auth restored, router mounted, conversation list loaded. I hold the target conversation ID in a small holder that the home shell drains as soon as it is ready. Reports of the app opening to nothing from a terminated state went to zero.
The iOS Notification Service Extension
Once the client owned rendering, I wanted sender avatars, and a chance to decrypt or reshape content before it is shown. On iOS that means a Notification Service Extension, a small separate target that intercepts the push before the banner is drawn.
Two things bit me. The extension only runs if the payload is flagged mutable, so the server has to set mutable-content. Miss it and the extension never runs, and you never notice because the plain notification still shows. It also has a short, hard time budget: download an avatar at full resolution over a slow network and the system kills the extension and shows the original payload. I cache avatars and fall back fast. The extension is also a good place to reconcile the badge, which brings me to the last bug.
Android Channels, Grouping, and Badge Drift
On Android every notification belongs to a channel, and once created the channel owns the sound, vibration, and importance, not your code. I created a "messages" channel with default importance, later decided messages should be high importance, and found that changing it in code does nothing: the channel already exists on the device. You ship a new channel ID or the user clears app data. I treat channel IDs as versioned and immutable now.
Without a group key, ten messages from one chat become ten banners. I set a group key per conversation and post a summary notification, so a busy conversation collapses into one expandable stack.
Badge drift was the long tail. The icon count and the real unread count slowly diverged, because I incremented on every push and decremented on every read, and pushes get dropped, delayed, or delivered twice. Any counter built from deltas drifts. The server now includes the unread count in the payload and the client sets the badge to that absolute number. Set, never increment. The drift stopped.
Key Takeaways
- Send data messages only: the client becomes the only thing that draws a notification, so the OS and your code never render the same push.
- Dedup by a stable message ID: the same server ID must ride the realtime stream and the push payload, and a deterministic notification ID turns a repeat into an update in place.
- Treat foreground, background, and terminated as three producers: each can show an alert on its own, so funnel all three through one path that dedups and renders.
- Buffer deep links until the app is ready: from a terminated state your router and auth are not mounted when the tap fires, so hold the target and replay it after boot.
- Set the badge to an absolute count: badges built from increment and decrement deltas drift, because pushes are dropped, delayed, and duplicated; let the server send the number.