Push & Local Notifications — An Expert Technical Study Across Android, iOS, macOS, Web, and Windows Part1


Shailesh Maurya is a proficient software developer with expertise in frontend technologies, specializing in React and Node.js. He delivers robust, scalable web solutions with a focus on performance and user experience.
A notification is an OS-mediated message surface: the operating system — not your app — renders a piece of content on behalf of your app, outside of your app's own UI. That single sentence explains almost everything difficult about notifications. Because the OS owns the surface, the OS also owns permission, timing, throttling, grouping, presentation, and suppression . Your backend can "send" a notification with a 200 response from the push provider and the user may still never see it.
There are two fundamental classes:
- Local notifications — scheduled and displayed entirely on-device (alarms, reminders, geofence triggers). No network, no push provider, but still subject to permission and OS display rules.
- Remote (push) notifications — originate on your server, travel through a platform push service (FCM, APNs, WNS, or a browser push service) over a single persistent, OS-owned socket to the device, and are then routed to your app or displayed directly by the system.
Why notifications matter
Notifications are the only channel through which a product can initiate contact with a user who is not currently in the product. Every other surface — home screen, feed, email inbox — waits for the user to arrive. This gives notifications a unique, measurable role across the funnel:
- Attention: they interrupt (or gently surface into) whatever the user is doing, with OS-level prominence no in-app banner can match.
- Retention: well-targeted transactional and behavioral notifications are consistently among the strongest levers for D7/D30 retention; users who opt in to push retain at materially higher rates than those who don't (partly causal, partly selection — measure both, see Section 11 ).
- Engagement: they create re-entry points — a reply, a like, a price drop — that convert dormant sessions into active ones, with deep links carrying the user straight to the relevant screen.
- Conversion: cart abandonment, expiring offers, and payment/OTP flows depend on timely delivery; a delayed OTP push is a failed login.
- Real-time responsiveness: chat, calls, rides, deliveries, and trading apps are only viable because push infrastructure keeps a warm path to a device whose app process may not even be running.
The cost side: overuse is a product defect
Notifications draw on a finite, shared budget of user tolerance. The failure modes are well documented and asymmetric — one bad campaign can undo months of trust:
- Permission revocation: on iOS the user can silence you from the notification itself ("Turn Off…"); on Android 13+ they were never opted in by default. Once revoked, you have effectively lost the channel — re-prompting is heavily restricted.
- Channel-level muting on Android: users can kill exactly the category you abused (e.g. "Promotions") while keeping transactional ones — if you designed channels properly. If you shoved everything into one channel, one mute kills everything.
- Uninstall: notification frequency is a leading predictor of uninstalls; every mature push platform tracks "uninstall within 24h of a campaign" as a first-class metric.
- OS-level demotion: both Android (notification importance learning, App Standby Buckets) and iOS (Focus, notification summaries, delivery scheduling) actively de-prioritize apps whose notifications users ignore or dismiss.
Teams treat "provider accepted the message" (HTTP 200 from FCM/APNs) as "delivered". It is not. Between provider acceptance and a pixel on screen sit: device offline windows, Doze/App Standby, OEM battery killers, Focus modes, notification permission state, channel importance, muted threads, and payload errors in your own client handler. A serious notification system instruments the entire funnel — enqueued → sent → provider-accepted → device-received → displayed → tapped — and treats each drop-off as a distinct engineering problem.
How to read this study
Sections 2–8 cover platform mechanics. Sections 9–10 cover the server side and security. Section 11 covers product strategy. Sections 12–15 are operational: checklists, edge cases, diagrams, and review guidance. Code samples show real payloads for APNs, FCM, Web Push, and VoIP PushKit. Where behavior differs by OS version, the version is stated; where behavior is undocumented but consistently observed in production (OEM push killers, silent-push budgets), it is labeled as such.
Platform-by-Platform Architecture
Every platform implements the same conceptual pipeline — app registers with a push service → receives an opaque token/handle → app sends token to your backend → backend posts payloads to the push service → push service delivers over one OS-owned persistent connection → OS routes to the app or renders directly. The differences that matter live in permissions, background execution, throttling, and failure semantics.
| Dimension | Android | iOS | macOS | Web / Browser | Windows |
|---|---|---|---|---|---|
| Push service | Firebase Cloud Messaging (FCM) | Apple Push Notification service (APNs) | APNs (same infra as iOS) | Browser-vendor push service (Chrome→FCM infra, Firefox→Mozilla autopush, Safari→APNs) | Windows Push Notification Services (WNS) |
| Device handle | FCM registration token (per app install) | APNs device token (per app/device, hex) | APNs device token |
PushSubscription
(endpoint URL + encryption keys) |
Channel URI (expires ≈30 days) |
| Server auth | OAuth2 service account (HTTP v1 API) | JWT (
.p8
key, ES256) or TLS client cert |
Same as iOS | VAPID (self-signed ES256 JWT) | Microsoft Entra / OAuth2 client credentials |
| Permission model | Runtime permission (13+); channels per category | Explicit prompt; provisional (quiet) opt-in available | Explicit prompt; alert vs banner style | Explicit prompt, must follow user gesture in most browsers | Enabled by default for installed apps; user toggles per app |
| Silent / data push | Data messages (may be deferred/killed by OEM) |
content-available:1
, budgeted, priority 5 only |
Same as iOS, laptops less battery-constrained | Push event in service worker (must usually show a notification) | Raw notifications (require background task) |
| Payload limit | 4096 bytes | 4 KB (5 KB for VoIP) | 4 KB | ~4 KB after encryption overhead | ~5 KB (toast XML) |
| Delivery guarantee | Best effort; collapse + TTL | Best effort; stores 1 last message per device when offline | Best effort | Best effort; TTL header | Best effort; cache one per type |
Android Notification stack
Delivery architecture. Google Play services maintains a single persistent connection to FCM per device. Remote messages arrive over that socket; notification messages targeting a backgrounded app are posted to the system tray directly by Play services, while data messages are handed to your
FirebaseMessagingService.onMessageReceived()
. Local notifications go straight through
NotificationManager.notify()
. Display is governed by notification channels (API 26+): the user, not the app, ultimately controls importance, sound, and vibration per channel.
- Local vs remote: local =
NotificationCompat.Builder+AlarmManager/WorkManagerscheduling; remote = FCM. Both converge on the same channel/permission rules at display time. - Registration: app start →
FirebaseMessaging.getInstance().token→ send to backend; token rotates (reinstall, restore, data clear) viaonNewToken(). - Background behavior: high-priority FCM grants a short (~10s historically, now tighter) execution window even in Doze; normal priority is batched to maintenance windows. OEMs (Xiaomi, Oppo, Vivo, some Samsung modes) add proprietary killers that silently drop data messages for "non-whitelisted" apps.
- Rich/actions: BigText/BigPicture/Inbox/Messaging styles, up to 3 action buttons, direct-reply
RemoteInput, progress, grouping with summary, bubbles for conversations. - Reliability limits: no delivery receipt from the OS; FCM offers BigQuery export + delivery data API for aggregate insight only.
- Common mistakes: relying on
onMessageReceivedfor notification-type messages in background; creating channels lazily after the first push arrives; not requestingPOST_NOTIFICATIONSon 13+; mutablePendingIntents crashing on 12+.
iOS Notification stack
Delivery architecture. One system daemon (
apsd
) holds a single TLS connection to APNs for the whole device. Your provider server posts HTTP/2 requests to APNs; APNs routes by device token + topic (bundle ID). Visible alerts are rendered by the system from the
aps
dictionary — your app is not woken for a plain alert push unless it includes
mutable-content
(Notification Service Extension) or
content-available
(background push). Local notifications use
UNUserNotificationCenter
with time/calendar/location triggers.
- Permission:
requestAuthorization(options:)— one shot at the full prompt; provisional authorization delivers quietly to Notification Center without a prompt, letting the user upgrade/downgrade from the notification itself. - Registration:
registerForRemoteNotifications()→didRegisterForRemoteNotificationsWithDeviceToken; token can change on restore/reinstall — re-register on every launch and sync to backend. - Background: strictly budgeted. Background pushes (priority 5) may be delayed, coalesced, or dropped; never delivered to an app the user force-quit.
- Rich/actions: categories with action buttons and text input; attachments (image/audio/video) added via Service Extension; custom UI via Content Extension; communication notifications with sender avatars; Live Activities updated via a dedicated APNs push type.
- Throttling / privacy: interruption levels (passive → critical), Focus modes, Scheduled Summary, per-app notification previews hidden on lock screen by policy.
- Common mistakes: testing against the sandbox APNs host with production tokens (or vice versa) →
BadDeviceToken; assuming silent pushes are timely; not calling the Service Extension's content handler within its ~30s limit (system shows original payload).
macOS Notification stack
macOS shares APNs and the
UserNotifications
framework with iOS — same tokens, topics, payloads, and provider API — with desktop-specific behavior on top:
- Presentation styles: user chooses banner (transient) or alert (persistent, requires action) per app; apps can express a preference (
NSUserNotificationAlertStylein Info.plist) but the user decides. - Delivery: laptops on AC power are far less throttled than iPhones; background (
content-available) pushes are more reliable but still not guaranteed. Notification Center groups by app with per-app grouping settings. - Distribution nuance: sandboxed Mac App Store apps and notarized direct-distribution apps both use APNs, but the
aps-environmententitlement and provisioning must match — the classic "works in dev, silent in prod" failure. - Web overlap: Safari on macOS supports standards-based Web Push (Safari 16+/macOS 13+), so a Mac user may receive your "web" notification and your "native" one — deduplicate by user, not by platform.
- Edge cases: Focus sync across Apple devices suppresses on all of them at once; closed-lid/sleeping Macs won't display until wake, so time-sensitive content needs server-side expiry (
apns-expiration).
Web Browser / Web Push stack
Delivery architecture. Three W3C/IETF pieces: the Push API (subscription + delivery to a service worker), the Notifications API (display), and RFC 8030/8291/8292 (Web Push protocol, payload encryption, VAPID). Each browser vendor runs its own push service; your server talks to whatever endpoint the subscription contains — you never hardcode a provider.
- Registration:
serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly:true, applicationServerKey})returns an endpoint +p256dh/authkeys; persist all three server-side. - Permission: user-gesture-gated prompt in Safari/Firefox (and effectively in Chrome via quieter UI); denial is sticky and often permanent for the origin.
- Background: the service worker wakes for
pushevents with tight CPU/time budgets; Chrome enforcesuserVisibleOnly— a push that shows no notification earns a browser-generated "site updated in background" warning and eventually revocation. - Payloads: must be encrypted (aes128gcm) to the subscription keys; ~4 KB effective limit.
- Differences: Safari requires iOS/iPadOS PWAs to be installed to Home Screen (16.4+) before push is available; Firefox allows pushes without notifications within quota; Chrome ties delivery to Google's push backbone (blocked in some networks/regions).
Windows Notification stack
Delivery architecture. WNS delivers toast, tile, badge, and raw notifications to a channel URI obtained by the app. The toast XML schema defines content, buttons, inputs, images, and activation arguments; Action Center retains dismissed toasts. Packaged (MSIX) apps get identity automatically; unpackaged Win32 apps need identity via sparse packaging or must use the Windows App SDK push APIs / local-only toasts with an AUMID.
- Local vs remote: local toasts via
ToastNotificationManager/ community toolkit; remote via WNS POST with OAuth token. - Background: raw notifications only reach a running app or a registered background task; toasts display without app involvement but activation (button click) needs a COM activator for desktop apps.
- Reliability: channel URIs expire (~30 days) — refresh on every launch; WNS caches at most one notification per type per channel while the device is offline (with
X-WNS-Cache-Policy). - Throttling: Focus Assist (Do Not Disturb) suppresses toasts into Action Center; per-app user toggles; battery saver defers background tasks.
- Common mistakes: shipping an unpackaged app and wondering why toasts have no icon/name or activation fails; treating the channel URI as permanent; using raw notifications as a message bus without a fallback poll.
On every platform, one OS-owned connection multiplexes all apps' pushes . This is why third-party "keep-alive" hacks, resident background services, and self-hosted socket connections are throttled or banned: the platform's entire battery story depends on there being exactly one radio-waking channel. Design your system to ride that channel correctly rather than fight it.
Android Deep Dive
FCM architecture
FCM has three tiers: your app server (authenticated via an OAuth2 service-account token against the HTTP v1 API
projects/<id>/messages:send
), the FCM backend (accepts, queues, applies collapse/TTL, fans out to topics), and the on-device transport inside Google Play services. The legacy server key API was shut down in 2024 — all new integrations use HTTP v1, which also enables per-platform overrides in a single message.
Notification messages vs data messages
| App state |
notification
message |
data
message |
notification
+
data
|
|---|---|---|---|
| Foreground |
onMessageReceived()
— nothing shown automatically ; you must build the notification |
onMessageReceived()
|
onMessageReceived()
with both payloads |
| Background | System tray renders it; app never runs. Tap → launcher activity with extras |
onMessageReceived()
(high priority, subject to Doze/OEM) |
Tray shows
notification
;
data
arrives in the launch intent extras — not in
onMessageReceived
|
| Killed / force-stopped | Tray (if process merely dead); dropped if user force-stopped the app | Delivered if process dead; dropped if force-stopped | As above |
Production systems overwhelmingly converge on data-only messages with the client building every notification. That gives one code path for all states, full control over channels, images, grouping, dedup, and analytics — at the cost of owning delivery in Doze/OEM-restricted environments. If you can't afford that risk for a given message class (e.g. marketing), use notification messages for those and data messages for the rest.
Notification channels (API 26+)
- Create all channels at app start (idempotent) — never lazily on first message. A push arriving before its channel exists is silently dropped on some OEMs.
- Importance is a user-owned setting after creation : you cannot programmatically raise it later. Choose initial importance carefully; migrating means creating a new channel ID.
- Design channels around user intent (Messages, Order updates, Promotions, Silent sync), not internal taxonomy. Deleted-and-recreated channels retain the user's old settings — resetting requires a new ID, which Play policy treats as abuse if done to un-mute users.
Runtime permission on Android 13+ (API 33)
POST_NOTIFICATIONSis a runtime permission; apps targeting 33+ get no notifications until granted . Apps targeting ≤32 on a 13+ device trigger a system prompt at first channel creation — another reason to create channels deliberately, at a moment you choose.- Best practice: show an in-app pre-prompt explaining value, then request in a context where the benefit is obvious (after first order, first message). You get limited refusals before "don't ask again" semantics apply (
shouldShowRequestPermissionRationaleto detect). - Even when denied, you can still post foreground-service notifications in a demoted form and media-style notifications; everything else is suppressed.
Doze, App Standby Buckets, and OEM restrictions
- Doze (screen off, stationary, unplugged): network and jobs deferred to maintenance windows; high-priority FCM is the sanctioned escape hatch, granting brief network + execution.
- App Standby Buckets (active → working set → frequent → rare → restricted): lower buckets quota high-priority messages — an app in rare/restricted may see its "high priority" pushes delivered as normal priority. Buckets are driven by real usage; you cannot buy your way out.
- OEM battery managers (documented exhaustively by dontkillmyapp.com): several Chinese OEMs force-stop backgrounded apps, which — per the matrix above — kills even data-message delivery. Mitigations: high-priority only for genuinely urgent messages (Google monitors and de-prioritizes abusers), instruct affected users to whitelist, and always design a pull-based reconciliation path (see Section 6 ).
Google enforces a high-priority quota per app per device . If your high-priority messages don't visibly result in user-facing work (notification shown, call started), FCM begins deprioritizing them to normal priority — silently. Symptom: "pushes are slow only for our most-spammed users." Fix: reserve high priority for calls/messages/alerts; send everything else at normal priority with a sensible TTL.
Exact alarms vs push
- For device-local schedules (medication reminder at 9:00), use
AlarmManager.setExactAndAllowWhileIdle()— but Android 12+ gates it behindSCHEDULE_EXACT_ALARM(user-revocable special access) and Android 14 denies it by default for most apps;USE_EXACT_ALARMis reserved for alarm/calendar apps per Play policy. - Rule of thumb: server-known events → push; user-local schedules → exact alarm (if eligible) or
WorkManagerwith a tolerance window; never push-at-scheduled-time from the server for thousands of users at once (thundering herd + delivery jitter).
Foreground services
Long-running user-visible work (calls, navigation, playback, uploads) runs in a foreground service with a mandatory persistent notification. Android 14+ requires a declared
foregroundServiceType
(
phoneCall
,
mediaPlayback
,
dataSync
…) with runtime prerequisites; Android 12 restricts starting FGS from the background — the exemption you rely on for calls is: high-priority FCM → start FGS within the grant window . Miss the window and you get
ForegroundServiceStartNotAllowedException
.
Deep links and PendingIntents
- Targeting 31+, every
PendingIntentmust declareFLAG_IMMUTABLE(default choice) orFLAG_MUTABLE(only for direct-replyRemoteInput, bubbles). - Android 12 banned notification trampolines : the tap must launch an Activity directly — no Service/BroadcastReceiver that then calls
startActivity(). Route analytics through intent extras processed in the Activity. - Use
TaskStackBuilderso Back from a deep-linked screen lands on your app's natural parent, not the launcher. Always validate the deep-link target still exists (see Section 13 ).
Token refresh, dedup, and analytics
onNewToken()fires on install, restore, data-clear, and periodic rotation. Upload with{userId, installationId, token, platform, appVersion, timestamp}; server upserts by installation, not by token (see Section 9 ).- Duplicate prevention: carry a server-generated
notificationId/dedupKeyin the data payload; client checks a small Room table of recently displayed IDs before posting, and uses a stablenotify(tag, id)so re-sends update rather than stack. FCM'scollapse_keyonly dedupes undelivered messages on the FCM side (max 4 distinct collapse keys in flight). - Debugging delivery: FCM Delivery Data API / BigQuery export for aggregates;
adb shell dumpsys notificationanddumpsys deviceidleon-device; log received-at vs sent-at timestamps in the payload to quantify latency per OEM/bucket.
{
"message": {
"token": "dGVzdF90b2tlbl9leGFtcGxl...",
"data": {
"type": "chat_message",
"conversationId": "c_88412",
"messageId": "m_559201", // dedup key
"sentAt": "2026-07-07T09:14:03Z" // latency measurement
},
"android": {
"priority": "HIGH", // only for genuinely urgent classes
"ttl": "120s", // stale chat ping is worthless
"collapse_key": "chat_c_88412" // keep newest while device offline
}
}
}
Apple Platform Deep Dive — APNs
APNs architecture and provider flow
APNs is a globally distributed courier: your provider server opens long-lived HTTP/2 connections to
api.push.apple.com:443
(or
:2197
) and POSTs one request per notification to
/3/device/<device-token>
. APNs authenticates you, validates the token against the topic , and forwards over the single per-device TLS connection maintained by
apsd
. If the device is offline, APNs performs store-and-forward of exactly one message per device/topic (Quality of Service = 1) — older undelivered messages are discarded unless you manage this with collapse IDs and expiration.
Device token registration
UIApplication.registerForRemoteNotifications()→ callback with an opaque token. Tokens are per app, per device, per environment — sandbox and production tokens are different and mutually invalid.- Tokens can rotate on restore-from-backup, device transfer, or OS reinstall. Register on every launch and sync to your backend with app version and environment.
- Never parse or trim the token; treat it as opaque hex (length has changed historically and is not contractual).
Authentication: token-based (JWT) vs certificate-based
| Aspect | Token-based (.p8 / JWT) — recommended | Certificate-based (.p12) |
|---|---|---|
| Credential | One APNs Auth Key (ES256), never expires, works for all apps in the team and both environments | Per-app TLS client certificate, expires yearly |
| Mechanics | Sign a JWT (
iss
=Team ID,
iat
,
kid
=Key ID); send in
authorization: bearer
. Refresh every 20–60 min; reuse within that window (APNs rejects tokens refreshed too aggressively) |
Mutual TLS on the connection; no per-request auth header |
| Failure modes |
ExpiredProviderToken
(403) if stale;
InvalidProviderToken
if clock skew or wrong key |
Annual expiry outages; per-app cert sprawl |
Topics, environments, and headers
apns-topic= bundle ID for ordinary pushes; suffixed variants route special types:<bundle>.voip,<bundle>.complication,<bundle>.push-type.liveactivity.apns-push-typeis required (strictly enforced on watchOS, expected everywhere):alert,background,voip,liveactivity,location,fileprovider,mdm,complication. Mismatched type/payload combinations are grounds for delivery penalties.- Sandbox vs production:
api.sandbox.push.apple.comserves development-provisioned builds (Xcode runs); production serves TestFlight and App Store builds. Sending a sandbox token to production yieldsBadDeviceToken(400) — the single most common "push worked in dev, dead in prod" cause. Store the environment alongside every token.
Payload anatomy
{
"aps": {
"alert": {
"title": "Asha Verma",
"body": "Sent you 3 photos from the site visit",
"title-loc-key": "NEW_MSG_TITLE" // client-side localization
},
"badge": 4, // absolute value — server owns the count
"sound": "default",
"thread-id": "c_88412", // groups notifications per conversation
"category": "MSG_ACTIONS", // registered actionable category
"mutable-content": 1, // wake Notification Service Extension
"interruption-level": "active", // passive|active|time-sensitive|critical
"relevance-score": 0.8 // ordering inside Scheduled Summary
},
"messageId": "m_559201", // custom keys live outside aps
"mediaUrl": "https://cdn.example.com/t/559201.jpg"
}
alert/badge/sounddrive system presentation;badgeis absolute , which is why badge counts drift when multiple devices/servers disagree (Section 13).content-available: 1(background push) wakes the app briefly with no UI — see Section 6 for its heavy restrictions.mutable-content: 1routes through your Notification Service Extension : ~30 seconds and a tight memory cap to decrypt E2E content, download an attachment, rewrite text, or update a local database. If you don't call the content handler in time, the system displays the original payload — so always ship a sensible fallback body ("New message").- Notification Content Extension renders fully custom UI (maps, media players, message threads) when the user long-presses/expands a notification of a registered category. It cannot run arbitrary networking freely and its interactions are mediated through
UNNotificationContentExtensionaction handling.
Interruption levels, critical alerts, Focus
| Level | Behavior | Requirement |
|---|---|---|
passive
|
No sound/wake; appears in list | — |
active
(default) |
Normal banner + sound | — |
time-sensitive
|
Breaks through Focus & Scheduled Summary | Entitlement (standard grant) + honest use; users can strip it per app |
critical
|
Breaks through Focus and mute switch , plays at set volume | Special entitlement granted case-by-case (health, safety, home security); misuse → rejection |
Focus modes filter at delivery time on-device: your push is "delivered" from APNs' perspective but silently parked in Notification Center. Communication apps should adopt the relevant INSendMessageIntent donation / communication-notification APIs so Focus's "allow people" rules can identify senders.
Priority, collapse, expiration, throttling
apns-priority: 10— immediate delivery; only legal for user-visible pushes . Priority 10 on a background push is an error in intent and APNs may penalize the app.apns-priority: 5— power-considerate; may be coalesced or delayed. Mandatory forcontent-availablepushes. (Priority 1 exists for lowest-effort delivery.)apns-collapse-id— later pushes with the same ID replace an undelivered/displayed one (64 bytes max). Perfect for live scores and edited messages; wrong for anything where each event matters.apns-expiration— UNIX timestamp;0means "deliver now or never." Set aggressively for OTPs, ride updates, call-style alerts.- Throttling reality: alert pushes at priority 10 are effectively real-time when the device is reachable. Background pushes are budgeted (rule of thumb observed in production: a handful per hour, less in Low Power Mode, zero when force-quit).
Error handling and token hygiene
| HTTP | Reason | Correct action |
|---|---|---|
| 400 |
BadDeviceToken
|
Environment mismatch or corrupt token — check sandbox/prod routing, then drop token |
| 403 |
ExpiredProviderToken
/
InvalidProviderToken
|
Refresh JWT (clock-sync!), retry |
| 410 |
Unregistered
|
App uninstalled/token dead as of the returned timestamp — delete token unless re-registered after that time |
| 413 |
PayloadTooLarge
|
Trim payload (>4 KB / >5 KB VoIP) |
| 429 |
TooManyRequests
|
Too many sends to one device token — back off per-device |
| 500/503 | Server error / shutdown | Retry with exponential backoff + jitter on a new connection for 503 |
Handling
410 Unregistered
lazily poisons your metrics and your sender throughput: dead tokens accumulate, fan-outs slow down, and "delivery rate" collapses for reasons that have nothing to do with real users. Treat 400/410 responses as a synchronous cleanup signal — mark the token dead in the same transaction that records the send attempt, and compare the 410 timestamp against your latest registration to avoid deleting a token the device just renewed.



