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


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.
PushKit & VoIP Calls on iOS
What PushKit is — and why it exists
PushKit is a separate push framework for a small set of high-urgency, non-UI push types — VoIP being the canonical one. A VoIP push differs from a normal APNs push in three decisive ways:
- It launches or wakes your app unconditionally — even if the app was terminated by the system — and hands the payload to code, not to the notification UI. (User force-quit still blocks it on modern iOS unless a call is being reported.)
- It is always treated as maximum urgency : no coalescing, no summary, no Focus filtering of the wake-up itself — because a ringing call cannot wait.
- It carries a hard contract : since iOS 13, on receiving a VoIP push you must report an incoming call to CallKit before returning. Break the contract and iOS terminates the app and eventually stops delivering VoIP pushes to it entirely.
That contract is precisely why Apple restricts PushKit. Pre-iOS 13, apps abused VoIP pushes as an unlimited silent-push channel — free background execution for sync, tracking, and analytics — draining batteries at scale. The CallKit requirement makes every VoIP push publicly accountable: it must produce a ringing call UI.
Correct PushKit flow for a VoIP calling app
// App launch — register early, and keep the registry alive
let registry = PKPushRegistry(queue: .main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
// Delegate
func pushRegistry(_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic,
value: payload.dictionaryPayload["handle"] as? String ?? "Unknown")
// MUST happen before completion(), synchronously on receipt —
// even if you cannot yet validate the call. Report first, fail later.
provider.reportNewIncomingCall(with: callUUID, update: update) { error in
if error != nil { /* end the call gracefully via CXEndCallAction */ }
completion()
}
}
Token lifecycle and server-side details
- VoIP tokens are separate from ordinary APNs tokens: delivered via
pushRegistry(_:didUpdate:for:), invalidated viadidInvalidatePushTokenFor. Store them in a distinct column/type; never send a VoIP payload to an alert token or vice versa. - Topic:
<bundle-id>.voip; headerapns-push-type: voip; certificate-auth setups need the VoIP Services certificate — JWT auth covers it with the standard key. - Payload design: keep it to the minimum needed to ring — caller display name/handle, a call UUID, and a signaling hint. Fetch everything else after answer. Limit is 5 KB, but a ringing screen needs ~200 bytes. Never put media/SDP blobs in the push.
- Expiration: set
apns-expirationto now + ring timeout (e.g. 30–45 s). A VoIP push delivered two minutes late must not ring for a call that already went to voicemail — pair with a server-side "call cancelled" signal and have the client silently end the CallKit call if the cancel raced ahead.
Timing expectations and failure consequences
| Behavior | Consequence |
|---|---|
| Report to CallKit immediately, then complete | Normal operation; app gets background runtime for the call |
| Delay reporting (validate with a network call first) | Race with the watchdog: intermittent crashes (
0xBAD01DEA
-class terminations), missed rings on slow networks |
| Return without reporting a call | iOS 13+: app terminated; repeated offenses → system stops launching the app for VoIP pushes at all — your call channel is dead until reinstall |
| Use VoIP pushes for sync/analytics/generic wakeups | App Store rejection (Guideline 2.5.4-adjacent enforcement), plus the termination behavior above in the field |
PushKit is not a faster silent push. Every VoIP push must ring. If your product needs urgent-but-not-a-call delivery (new message while app killed), the sanctioned tools are a normal alert push with
time-sensitive
interruption level, a Notification Service Extension for enrichment, and — for genuine calls only — PushKit + CallKit. Apps that fake "incoming call" UX for engagement have been removed from the App Store.
Design the unhappy paths first: caller hangs up before ring (send a cancel push with the same call UUID; client reports call ended with reason
.remoteEnded
/
.answeredElsewhere
), callee answers on another device (
.answeredElsewhere
on the rest), and push-arrives-after-timeout (expiration + client-side sanity check on
sentAt
). These paths, not the happy ring, are what users describe as "your app shows ghost missed calls."
Silent Push & Real-Time Notification Requirements
Why silent push is not real-time — by design
A silent push (
content-available: 1
on iOS, data-only messages on Android) asks the OS to spend battery running your code with no user-visible payoff . Operating systems therefore treat it as a discretionary favor, not a delivery contract. Delay and drop causes stack multiplicatively:
- Priority rules: iOS background pushes must use
apns-priority: 5, which explicitly permits coalescing and deferral; APNs may also batch several into one wake-up, delivering only the newest per collapse ID. - Budgets: iOS grants each app a limited background-push budget influenced by user engagement, battery, and thermal state — in practice a few per hour, sometimes fewer.
- Device state: Low Power Mode slashes budgets to near zero; Background App Refresh off disables
content-availableprocessing entirely; user force-quit blocks silent pushes completely (iOS) and data delivery (Android force-stop); Doze/Standby buckets defer normal-priority FCM to maintenance windows; Focus doesn't block the data path but correlates with the states that do. - Network: the device's push socket rides radio wake-up cycles; on poor networks the OS batches to save power.
Silent-push reliability is per-user, not per-app : your heaviest users (app in bucket "active", charger nearby) see near-perfect delivery, so internal testing looks fine. Your churn-risk users (rare bucket, Low Power Mode, force-quit habit) see the worst delivery — exactly the users your re-engagement sync was meant to serve. Always segment delivery metrics by standby bucket / engagement tier before declaring silent push "reliable."
Correct architectures per use case
VoIP calls
Never silent push. iOS: PushKit + CallKit (Section 5). Android: high-priority FCM data message → full-screen intent notification (
USE_FULL_SCREEN_INTENT
, user-grantable on 14+) →
phoneCall
foreground service. Server sets tight expiration and sends explicit cancel events.
Chat / message sync
Visible push for the message itself (users want it); silent push only as a hint to prefetch. On every app foreground, run a reconciliation fetch keyed by a server cursor (
since=lastSyncToken
) so missed hints cost nothing. NSE on iOS can insert the message into the local DB at push time.
Live data (scores, prices, rides)
App foreground: WebSocket/SSE. Background: collapse-ID pushes (only latest state matters) or platform-native live surfaces — iOS Live Activities (dedicated APNs push type), Android ongoing/foreground-service notification with updates.
Server-side retry, idempotency, and fallback
- Idempotency: every logical event carries a stable
eventId; client dedupes on display and on DB insert. Retries then become safe by construction. - Retry policy: retry provider-level failures (5xx/connection) with exponential backoff + jitter; do not retry semantic rejections (400/410/413/403-auth) except after fixing the cause. Cap total attempts by message class (an OTP older than 5 minutes should die, a billing notice can retry for a day).
- Escalation ladder for must-see events: silent hint → (no client ack within N seconds) → user-visible push → (still no ack) → SMS/email fallback for the OTP/security class. The client "ack" is a lightweight API call your app makes when it processes the event — this is how you detect the silent tier failing per user and adapt.
- Race-proofing: pushes and API responses race. The client must treat push payloads as invalidation signals , not as source of truth: on receipt, fetch canonical state (or apply the payload only if its version ≥ local version).
WebSocket vs push — the trade-off table
| Dimension | WebSocket / SSE (your connection) | Platform push (OS connection) |
|---|---|---|
| Latency | ~RTT, tens of ms | Sub-second (high-priority, device reachable) to minutes (throttled) |
| Works when app killed | No | Yes (visible push) |
| Battery cost | You pay it; OS will kill background sockets | Amortized across all apps |
| Ordering / richness | Full duplex, ordered, arbitrary size | Small, unordered, best-effort |
| Infra you operate | Connection gateways, sticky routing, presence | Sender fleet only |
Hybrid architecture is the industry answer: WebSocket while the app is foreground/active (presence, typing, sub-second updates); push for everything else; reconciliation fetch stitching the two so neither channel needs to be lossless. The server publishes each event once to a fan-out layer that routes per-device based on connection state ("socket if connected, else push"), with idempotent event IDs making duplicate delivery across both channels harmless.
Browser / Web Push Deep Dive
The three-standard stack
Web Push is service-worker-centric: a worker registered by your page outlives the page and is woken by the browser for
push
events. The Push API handles subscription and delivery; the Notifications API handles display (
registration.showNotification()
); the wire protocol is standardized (RFC 8030) with mandatory payload encryption (RFC 8291,
aes128gcm
) and server identification via VAPID (RFC 8292).
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true, // required by Chrome
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
await api.saveSubscription(sub.toJSON()); // { endpoint, keys: { p256dh, auth } }
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? "Update", {
body: data.body, icon: "/icons/192.png", badge: "/icons/badge-72.png",
tag: data.dedupKey, // same tag replaces, doesn't stack
renotify: false,
data: { url: data.deepLink }
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
VAPID and the subscription lifecycle
- VAPID : your server holds one ES256 keypair; each push request carries a short-lived self-signed JWT (
aud= push-service origin,sub= mailto/URL contact, exp ≤ 24 h) plus the public key. The public key also binds the subscription — rotating VAPID keys invalidates every existing subscription ; treat the private key like a production credential. - Lifecycle: subscriptions die when the user clears site data, revokes permission, the push service rotates, or the browser expires them (Chrome enforces expiration; Safari subscriptions are comparatively long-lived). Handle: (a)
pushsubscriptionchangein the worker — resubscribe and re-upload; (b) HTTP404/410from the push service — delete server-side; (c) on every page load, comparepushManager.getSubscription()with what the server has.
Permission UX — the highest-stakes prompt on the web
- Browsers punish prompt abuse: Chrome and Firefox show quieter UI (crossed bell) for sites with poor accept rates or when prompting without engagement; Safari and Firefox require a user gesture outright. A denial is sticky per origin and users almost never dig into site settings to reverse it.
- Best practice mirrors mobile: contextual pre-prompt ("Get notified when your report is ready") → real prompt only on explicit click → graceful degradation (in-app inbox, email) on denial.
Sending: encryption and control headers
- Payloads are encrypted to the subscription's
p256dh/authkeys — the push service cannot read them. Use a maintained library (web-pushfor Node.js — a natural fit alongside your existing docx-generation stack — orpywebpush); hand-rolling RFC 8291 is a security bug factory. - Protocol headers:
TTL(seconds the service should hold an undelivered message — 0 = now-or-never),Urgency(very-low…high, lets the browser delay low-urgency pushes on battery),Topic(collapse key: newest replaces pending with the same topic).
Browser differences that bite
| Behavior | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Push service | Google (FCM infra) — unreachable in some regions/corporate networks | Mozilla autopush | Apple (APNs infra) |
| Notification required per push | Yes (
userVisibleOnly
) |
Quota-based tolerance | Yes — repeated silent pushes revoke the subscription |
| Prompt gating | Quieter UI heuristics | User gesture | User gesture |
| iOS availability | Uses WebKit on iOS → Safari rules apply | Home-Screen-installed PWA only (iOS 16.4+) | |
| Closed browser delivery | Desktop: only if a Chrome process runs (background mode) | Browser must run | macOS: delivered via system push daemon |
PWA considerations and edge cases
- Installed PWAs get app-like presence (icon/name in OS notification settings, badging API); on iOS, installation is a precondition for push — design the "Add to Home Screen" education flow as part of your opt-in funnel.
- Service worker updates: a waiting worker doesn't handle pushes until activated; a broken deployed worker silently eats pushes — version your worker and monitor a heartbeat push in synthetic tests.
- Edge cases: multiple browser profiles = multiple subscriptions for one human (frequency-cap per user, not per subscription); private windows don't allow push; enterprise policies can disable notifications wholesale; the OS-level Focus/Do-Not-Disturb still suppresses whatever the browser shows
Windows Notification Deep Dive
WNS architecture
Windows Push Notification Services mirrors the standard model: the client requests a channel URI (
PushNotificationChannelManager
in UWP/WinRT, or
PushNotificationManager
in the Windows App SDK for desktop apps), sends it to your server, and the server POSTs notifications to that URI with an OAuth2 access token (Microsoft Entra app credentials; the legacy Live SSO SID+secret flow is deprecated). Four notification types share the pipe:
- Toast — the visible notification (XML payload), lands in Action Center when dismissed/missed.
- Tile / badge — Start-surface updates (legacy-leaning post-Win10, still valid for badge counts).
- Raw — opaque data for your code; requires the app running or a registered background task, otherwise dropped or cached (one, with
X-WNS-Cache-Policy: cache) until next connect.
App identity — the make-or-break requirement
Toast attribution, settings presence, Action Center persistence, and activation routing all key off package identity . MSIX-packaged apps get it automatically. Classic unpackaged Win32 apps must either adopt packaging with external location (sparse package) or use the Windows App SDK's unpackaged-app support (which registers an AUMID and, for push, uses Azure-app-registration-based WNS access). Skipping this yields the classic symptoms: toasts with a generic PowerShell-style header, no per-app notification settings, and button clicks that launch nothing.
- Desktop apps handle toast activation via a registered COM activator (
NotificationActivator) or the App SDK'sAppNotificationManageractivation events — including the "app was closed, user clicked a week-old toast in Action Center" path. Your handler must cold-start into the deep-linked state from serializedarguments.
Toast payload and activation flow
<toast launch="action=openThread&threadId=c_88412" activationType="foreground">
<visual>
<binding template="ToastGeneric">
<text>Asha Verma</text>
<text>Sent you 3 photos from the site visit</text>
<image placement="appLogoOverride" hint-crop="circle" src="https://cdn.example.com/u/asha.png"/>
</binding>
</visual>
<actions>
<input id="reply" type="text" placeHolderContent="Type a reply"/>
<action content="Send" arguments="action=reply&threadId=c_88412"
activationType="background" hint-inputId="reply"/>
<action content="Mute" arguments="action=mute&threadId=c_88412" activationType="background"/>
</actions>
</toast>
activationType="foreground"brings the app up;"background"runs your activator without UI (inline reply).launch/argumentsstrings are your deep-link contract — version them, since Action Center may replay old ones.- WNS request headers that matter:
X-WNS-Type(wns/toast|tile|badge|raw),X-WNS-TTL,X-WNS-Tag+X-WNS-Group(replace/update semantics),X-WNS-Cache-Policy. Diagnostics come back inX-WNS-NotificationStatus,X-WNS-DeviceConnectionStatus,X-WNS-Debug-Trace.
Background tasks and Action Center behavior
- Raw-notification background tasks (
PushNotificationTrigger) run under resource policies: battery saver defers them; repeated CPU abuse gets the task throttled. Never architect Windows raw pushes as a guaranteed job queue — same reconciliation-fetch rule as mobile. - Action Center keeps up to ~20 toasts per app; use tag/group replacement so live-updating content (download progress, sports score) edits one entry instead of flooding. Focus Assist routes toasts straight to Action Center silently — priority apps/people lists are user-controlled.
Common pitfalls
- Treating the channel URI as permanent — it expires (~30 days) and can change; re-request on every launch, upsert server-side, and handle WNS 404/410 by dropping the channel.
- Sending toast XML with unescaped user content —
&,<in a username breaks the whole payload (WNS 400). - Assuming delivery order or multiple offline queuing — WNS caches essentially one notification per type; design idempotent, state-carrying payloads.
- Forgetting that Microsoft Store policy and SmartScreen reputation don't fix identity — a signed installer still isn't package identity .
Backend Architecture
Service design: one pipeline, many providers
A production notification service is a pipeline with strict stage boundaries , not a helper function that calls FCM. The canonical shape:
- Ingestion API — internal services publish notification intents ("order 4417 shipped to user u_91") with an idempotency key; never raw device sends.
- Decision layer — preference center, quiet hours, frequency caps, dedup, channel selection (push vs email vs in-app), template + localization resolution.
- Fan-out — resolve user → active devices/subscriptions; emit one delivery job per endpoint onto a queue.
- Provider workers — per-provider sender pools (APNs HTTP/2 connection pools, FCM batches, WNS, Web Push) implementing that provider's auth, rate, and error semantics.
- Feedback loop — provider responses, client display/open acks, and token-invalidation events flow back into the device registry and analytics.
Token/device registry schema
-- One row per app installation per user session context
CREATE TABLE device_endpoints (
id UUID PRIMARY KEY,
user_id UUID NOT NULL, -- current owner (nullable pre-login)
installation_id TEXT NOT NULL, -- client-generated stable install UUID
platform TEXT NOT NULL, -- android | ios | ios_voip | macos | web | windows
provider TEXT NOT NULL, -- fcm | apns | apns_voip | webpush | wns
token TEXT NOT NULL, -- token / endpoint URL / channel URI
webpush_keys JSONB, -- p256dh + auth for web only
apns_environment TEXT, -- sandbox | production
app_version TEXT,
locale TEXT, timezone TEXT,
status TEXT NOT NULL DEFAULT 'active', -- active | invalid | logged_out
last_seen_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (installation_id, platform) -- upsert target: token rotates, install persists
);
CREATE INDEX ON device_endpoints (user_id) WHERE status = 'active';
CREATE UNIQUE INDEX ON device_endpoints (provider, token);
- Upsert by installation, not token: when
onNewToken/resubscribe fires, the row updates in place; a token seen on a different installation means a restore-clone — invalidate the older row (Section 13). - User–device mapping: login binds
user_id; logout must flip status (do not delete — you need the token to send nothing to it, and audit history). Multiple users on one device over time is normal; one user on many devices simultaneously is the default assumption.
Queueing, retries, DLQs
- Queue per priority class (realtime / transactional / bulk) so a 5M-user campaign cannot delay OTPs. Workers drain realtime first; bulk is rate-shaped.
- Retry taxonomy: transient (provider 5xx, 429, timeouts) → exponential backoff + jitter, bounded by the message's business TTL; permanent (400 malformed, 403 auth, 410 dead token) → no retry, fix or clean up. Respect per-provider signals: APNs 429 is per-token; FCM 429 wants global backoff on the indicated scope; WNS returns explicit retry headers.
- Dead-letter queue for jobs exhausting retries, with the full provider response attached. DLQ volume by error class is one of your best health dashboards; a spike in
ExpiredProviderTokenis an auth incident, a spike in 410s after a release is a client registration bug.
Dedup, collapse, priority, rate limiting
- Idempotency at ingestion (unique intent key, e.g.
order_shipped:4417) kills duplicates from upstream retries — the cheapest place to fix them. - Collapse keys end-to-end: map one logical stream to FCM
collapse_key, APNsapns-collapse-id, Web PushTopic, WNSTag/Group— so "latest state wins" is enforced by the platform, not just your client. - Priority mapping table owned by the platform team: message class → {FCM priority, APNs priority + interruption level, Web Push Urgency, TTLs}. Individual product teams request a class; they never set raw priorities. This is how you protect your high-priority quotas.
- Rate limiting on two axes: per-user experience caps (decision layer, e.g. max N promos/day, min gap between pushes) and per-provider throughput shaping (sender workers, token-bucket per connection) to ride large fan-outs without tripping provider limits.
Analytics, observability, and end-to-end tracing
- Propagate one
notificationTraceIdfrom intent through the payload to client ack events; join it with your distributed-tracing IDs so "why didn't user X get the push at 09:14" is a query, not an archaeology project. - Alert on ratios between funnel stages per platform per app version , not absolute counts: accepted→displayed collapse on Android usually means an OEM/channel/permission issue; sent→accepted errors are provider/auth; displayed→opened is a product problem.
- Standard SLOs: p95 intent→accepted latency for the realtime class; token-invalidation lag; DLQ rate; per-campaign uninstall/opt-out deltas (Section 11).
Security and Privacy
Threat model in one paragraph
Push payloads transit third-party infrastructure (Google, Apple, Microsoft, Mozilla), rest on lock screens readable by anyone near the phone, get mirrored to paired watches and connected cars, and are logged by client SDKs. Tokens are bearer-ish identifiers that let anyone holding your server credentials message your users. Registration APIs are an abuse surface. Design for all four.
Payload hygiene
- Never put secrets or sensitive content in the payload: no OTP-in-title for finance apps in sensitive contexts, no medical details, no full card numbers, no auth tokens, no PII beyond what the lock screen should show. The push service can technically see unencrypted native payloads (Web Push is the exception — encrypted by design).
- Pointer pattern for sensitive apps: send an opaque reference (
{type:"secure_msg", id:"m_559201"}); the client (NSE on iOS,onMessageReceivedon Android) fetches or decrypts locally and rewrites the visible text. This is exactly how E2E messengers (Signal-style) render message previews without the plaintext ever touching APNs/FCM. - Respect platform preview settings: iOS "Show Previews: When Unlocked", Android lock-screen visibility (
VISIBILITY_PRIVATEwith a public redacted version). Provide the redacted variant yourself; don't rely on defaults.
Token and credential security
- Treat the APNs
.p8key, FCM service-account JSON, WNS client secret, and VAPID private key as tier-0 secrets: vault-stored, least-privilege, rotated on personnel change, never in client apps or repos. Any one of them = ability to notify (i.e., phish) your entire user base with OS-level credibility. - Device tokens are personal data (they identify a device/user pair): encrypt at rest, restrict read access, exclude from general logs, and purge per retention policy.
- Authenticated registration: the "register token" endpoint must require an authenticated session and bind token→user server-side. Unauthenticated registration lets an attacker attach their device to a victim's account (silent notification interception) or flood your registry. Rate-limit and anomaly-detect it (many users on one token, rapid token churn per IP).
Consent, GDPR/DPDP, and the preference center
- OS permission ≠ legal consent. Transactional/service messages generally ride legitimate interest or contract; marketing pushes need their own consent under GDPR/ePrivacy — and India's DPDP Act pushes the same direction for Indian users — recorded with timestamp, surface, and wording.
- Ship a preference center : per-category opt-in/out, quiet hours, frequency choices, mapped 1:1 to Android channels and mirrored server-side (so a channel muted on one device suppresses email-push duplicates and applies to the user's other devices where sensible).
- Data-subject rights: deletion must cascade to tokens, notification history, and analytics events; export must include notification consent state. Set retention limits on notification logs (payload metadata, not content, wherever possible).
Abuse prevention and auditability
- Internal misuse is the common incident: a bad segment query pushes a test to production, or a compromised CMS account blasts spam. Mitigate with send authorization (which service/human may send which class to which audience size), staged rollouts with automatic kill switches on opt-out spikes, and a mandatory dry-run/estimate step for audiences above a threshold.
- Audit log every send decision : who/what triggered it, template version, audience definition, counts per funnel stage. This is both your compliance artifact and your best incident-forensics tool.
Add a "lock-screen test" to design review: print every notification template exactly as it appears on a locked phone and ask what a stranger, a partner, or an employer learns from it. "Your test results are ready" from a named clinic app leaks more than most teams intend. App name + template together are the disclosure, not just the body text.



