One notification API, four delivery systems that behave differently
Channels, categories, a service worker and a tray have different rules.
TL;DR
- A shared
notify(title, body, groupKey)that grouped correctly on Android and produced nine separate banners on iOS for nine messages in one conversation — because iOS groups bythreadIdentifier, which our shared call had no field for. - Four delivery systems with four grouping models, four permission lifecycles and four scheduling guarantees. The payload is portable; none of the behaviour is.
- The fix is a shared payload plus a per-platform
deliveryblock, and a capability report so the app knows which guarantees it did not get. - What reading on buys: the four grouping models, the permission lifecycle differences that broke our re-prompt, and the measurement that showed 31% of iOS users had silently revoked.
The case this post is about
A messaging notification, shared:
// commonMain/kotlin/notify/Notifier.kt
expect class Notifier {
suspend fun notify(
id: String,
title: String,
body: String,
groupKey: String, // "conversation:7f2a"
)
suspend fun requestPermission(): Boolean
}
// androidMain
actual suspend fun notify(id: String, title: String, body: String, groupKey: String) {
NotificationManagerCompat.from(ctx).notify(id.hashCode(),
NotificationCompat.Builder(ctx, CHANNEL_MESSAGES)
.setContentTitle(title).setContentText(body)
.setGroup(groupKey) // groups into a bundle
.build())
}
// iosMain
actual suspend fun notify(id: String, title: String, body: String, groupKey: String) {
val content = UNMutableNotificationContent().apply {
setTitle(title); setBody(body)
// groupKey has nowhere to go that affects grouping
}
UNUserNotificationCenter.currentNotificationCenter()
.addNotificationRequest(UNNotificationRequest.requestWithIdentifier(id, content, null), null)
}
The symptom
Nine messages arriving in one conversation while the app was backgrounded:
android 1 bundled notification, "9 new messages"
ios 9 separate banners, stacked, each one buzzing
desktop 9 toasts, 3 of which were dropped by the OS as a flood
web 1 notification, replacing the previous one each time
Four behaviours from one call. The iOS one generated a support complaint about "notification spam" from a user who had received nine messages — which is not spam, it is nine notifications for nine messages, and every other platform had made a decision about that which iOS had not been told.
And a second symptom found while instrumenting the first:
# iOS users with notifications enabled, per our own records vs the system
our stored flag "permission granted" 100% (of users who had tapped allow, ever)
system authorization status, queried 69%
Thirty-one percent had revoked in Settings and we were still calling notify, which succeeds and
delivers nothing.

Why the obvious fix failed
The first fix was to pass the group key as a thread identifier, which is the right field:
content.setThreadIdentifier(groupKey)
That groups on iOS. It also does nothing about the summary: Android's bundle shows "9 new messages" because we build a summary notification; iOS shows nine stacked notifications in a thread, collapsed, which is the correct iOS behaviour and looks different. There is no shared "grouped" result, and expecting one was the mistake.
The second fix was to suppress duplicates ourselves — keep a count and notify once:
if (pendingByGroup[groupKey]?.size ?: 0 > 1) notifySummaryOnly(groupKey)
Which reimplements Android's bundling badly, breaks iOS's own thread collapsing, and on the web replaced the single notification with a different single notification. A shared implementation of a feature every platform already has.
The third attempt was to query permission before each notify, which fixed the 31% and was far too slow:
if (!requestPermission()) return // ← on iOS this can SHOW A PROMPT
requestPermission on iOS shows the system prompt if the status is notDetermined, so calling it
defensively before every notification prompted users who had never been asked — at a random moment,
with no context, which is the worst possible time to ask. Querying and requesting are different
operations and our API had one method for both.
The actual mechanism
Four systems, and the differences are in behaviour rather than availability.
Android groups by a group key plus a summary notification you build. Channels — created once, user-configurable per channel — carry importance, sound and the ability to be disabled individually. A notification to a disabled channel succeeds and shows nothing.
iOS groups by threadIdentifier and collapses automatically. There is no summary to build.
Categories carry actions rather than importance. Authorization is one switch for the app, with
sub-options (banners, sounds, badges) the user controls separately — so "authorized" is not one
boolean.
Desktop toasts have no grouping and an OS-level flood limit. Three of nine were dropped by the system, silently, which is a rate limit rather than a bug.
The web replaces by tag. A notification with the same tag replaces the previous one — which
is a third grouping model, neither bundling nor threading, and it is why nine messages showed one
notification with the ninth message's text.
And the permission lifecycles differ in a way that broke our re-prompt:
android may be asked repeatedly; a denial is revocable by asking again (pre-13: granted by default)
ios asked ONCE. A denial is permanent from the app's side — only Settings can reverse it
web tied to a user gesture; a denial is "denied" and the prompt cannot be shown again
desktop no prompt at all on most configurations
So "ask for permission" is one operation on one platform and a one-shot irreversible decision on
two others — and our single requestPermission() was being called defensively, which on iOS burns
the one prompt the app will ever get.

The fix
A shared payload, a per-platform delivery block, and separate query and request operations.
// commonMain/kotlin/notify/Notification.kt
//
// The portable part: what the notification SAYS. Everything about how it behaves is in the
// per-platform block below, because none of it is portable and a shared field that only one
// platform honours is worse than no field.
data class NotificationPayload(
val id: String,
val title: String,
val body: String,
val deepLink: String? = null,
)
/**
* Per-platform delivery, supplied by the CALLER because only the caller knows the intent —
* "these are messages in one conversation" — and each platform expresses that differently.
*/
data class Delivery(
/** Android: groups into a bundle. iOS: threadIdentifier. Web: tag (REPLACES). Desktop: ignored. */
val groupKey: String? = null,
/** Android only: which channel, and therefore which user-controlled importance. */
val androidChannel: String = Channels.DEFAULT,
/** Android only: the summary's text when a bundle has more than one member. */
val androidSummary: ((count: Int) -> String)? = null,
/** Desktop only: coalesce locally, because the OS drops a flood without telling you. */
val desktopCoalesceWindow: Duration = 5.seconds,
)
sealed interface PermissionStatus {
data object Granted : PermissionStatus
/** Never asked. A request here will SHOW A PROMPT — ask at a moment that makes sense. */
data object NotDetermined : PermissionStatus
/** Asked and refused. On iOS and the web this is final; only Settings can change it. */
data class Denied(val canAskAgain: Boolean) : PermissionStatus
/** Granted at the app level and silenced for this channel. Android only. */
data class ChannelDisabled(val channel: String) : PermissionStatus
}
expect class Notifier {
/** Never prompts. Safe to call before every notify, and the 31% is why you should. */
suspend fun status(channel: String = Channels.DEFAULT): PermissionStatus
/** MAY prompt, exactly once on two platforms. Call from a deliberate, explained moment. */
suspend fun requestPermission(): PermissionStatus
suspend fun notify(payload: NotificationPayload, delivery: Delivery): Boolean
}
// iosMain — the grouping field the shared call had nowhere to put
actual suspend fun notify(payload: NotificationPayload, delivery: Delivery): Boolean {
// Checked, not requested. The original called requestPermission defensively, which on a
// notDetermined status shows the prompt — burning the single prompt iOS allows, at a
// moment the user has no context for.
if (status() != PermissionStatus.Granted) return false
val content = UNMutableNotificationContent().apply {
setTitle(payload.title)
setBody(payload.body)
// iOS's grouping mechanism. Collapsing is automatic; there is no summary to build,
// and androidSummary is correctly ignored here rather than approximated.
delivery.groupKey?.let { setThreadIdentifier(it) }
payload.deepLink?.let { setUserInfo(mapOf("deepLink" to it)) }
}
return suspendCoroutine { cont ->
UNUserNotificationCenter.currentNotificationCenter()
.addNotificationRequest(
UNNotificationRequest.requestWithIdentifier(payload.id, content, null),
) { error -> cont.resume(error == null) }
}
}
And the call site, which now expresses intent once and accepts four behaviours:
// commonMain
suspend fun onMessageReceived(msg: Message) {
val sent = notifier.notify(
NotificationPayload(
id = msg.id,
title = msg.senderName,
body = msg.preview,
deepLink = "notes://conversation/${msg.conversationId}",
),
Delivery(
groupKey = "conversation:${msg.conversationId}",
androidChannel = Channels.MESSAGES,
androidSummary = { n -> "$n new messages" },
),
)
// A false return is not an error — it is "the user has this switched off", which the
// original API could not report because notify returned Unit and succeeds when muted.
if (!sent) pendingBadgeCount.increment()
}
And the output it produced, nine messages in one conversation:
$ ./tools/notification-probe.sh --messages 9 --conversation 7f2a
android 1 bundled notification · "9 new messages" · channel MESSAGES
ios 1 collapsed thread · 9 notifications inside it · threadIdentifier conversation:7f2a
desktop 2 toasts · 7 coalesced locally within the 5s window · 0 dropped by the OS
web 1 notification · tag conversation:7f2a · showing the latest message
delivery suppressed (permission): 0 of 4 platforms
Four platform-appropriate behaviours, and nothing dropped silently.
How we proved it
That groupKey had nowhere to go on iOS — read from the API surface rather than inferred from
the symptom:
UNMutableNotificationContent fields affecting grouping
threadIdentifier ← the mechanism
categoryIdentifier actions, not grouping
subtitle, body, title content
our shared call passed groupKey to: (nothing)
That 31% of iOS users had revoked. The measurement, from querying the system rather than reading our own flag:
our stored "granted" flag 100% of users who ever tapped allow
UNNotificationSettings.authorizationStatus 69% authorized
24% denied
7% notDetermined (never asked)
notify() return value, before always Unit — no way to observe it
Our flag recorded a historical event; the system records the current state, and nothing had been comparing them.
That calling requestPermission defensively burns the prompt. This is the one worth being
explicit about, because the defensive call looks careful:
status before requestPermission() does after
notDetermined SHOWS THE SYSTEM PROMPT granted or denied — permanently
denied returns denied, no prompt denied
granted returns granted, no prompt granted
A defensive call on a notDetermined user prompts them at whatever moment the code happened to
run. We measured the consequence on a cohort before separating the methods:
prompt shown from the onboarding screen, with an explanation allow rate 71%
prompt shown incidentally, before a notification allow rate 34%
Half the allow rate, from the same prompt at a worse moment.
That the desktop drops were an OS flood limit. Separated from our own behaviour by counting both ends:
notifications we posted 9
toasts the OS displayed 6
errors returned to us 0
Three dropped with no error, which is why the local coalescing window exists rather than relying on the system.
That the web's tag replaces rather than groups. Verified because it reads like grouping in the
result and is a different mechanism:
9 notifications, same tag 1 shown, containing message 9 (messages 1–8 never seen)
9 notifications, unique tags 9 shown, stacked
Replacement is correct for a "latest state" notification and wrong for messages, so the messages channel uses the conversation id as a tag deliberately and the body says "9 new messages" rather than quoting the ninth.
What I cannot tell you is the Android picture across OEMs. Several vendors apply their own notification throttling and grouping on top of the platform's, and our probe ran on two devices. The bundling worked on both; I would not claim it works everywhere.
And an honest asymmetry. Delivery has three Android-only fields and one desktop-only field, so
it reads as Android-centric. That is because Android exposes the most control, and the alternative
— a lowest-common-denominator struct — is what produced nine banners. A caller setting
androidSummary on a platform that has no summaries is writing a line that does nothing, which is
clearer than a shared field that silently means something different per platform.

What this generalizes to
When four platforms all have a feature, the temptation is a shared API over the intersection — and the intersection is usually the payload, with every behavioural difference falling outside it. A field that only one platform honours is worse than no field, because it reads as configuration and is a no-op; the honest shape is a shared payload plus a per-platform block where the asymmetry is visible. And where a platform's operation is irreversible — one permission prompt, ever — the API must separate querying from requesting, or careful code will spend it.
Related
- There is no shared background work — the scheduling half, where the guarantees differ as much as the grouping does here.
- There is no shared permission API — the prompt lifecycle in detail, across all four.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".