Common Push Notifications Bugs and How to Catch Them
Common Push Notifications Bugs and How to Catch Them
Common Push Notifications Bugs and How to Catch Them
Push notifications are a fragile integration point between backend services, platform SDKs, and the host operating system. A missing permission, a malformed payload, or an OS‑specific quirk can turn a useful alert into a silent failure that frustrates users and skews engagement metrics. This guide walks through twelve recurring bug patterns, shows exactly how each manifests to users, gives reproducible steps, outlines detection tactics (both manual and automated), and provides concrete fixes and preventive measures.
Understanding Push Notification Failures
Before diving into specific bugs, it helps to map the notification lifecycle:
- Registration – the app requests a token from FCM/APNs and forwards it to your server.
- Payload creation – the backend builds a JSON (FCM) or dictionary (APNs) payload, often adding custom keys for deep‑link handling.
- Transport – the message travels via FCM/APNs to the device; network issues, token expiry, or throttling can intervene.
- Receipt – the OS wakes the app (or runs a service extension) and delivers the payload.
- Presentation – the notification is posted to the shade/lock screen; the UI may be customized (actions, images, grouping).
- Interaction – the user taps the notification; the app routes to a screen or performs a background task.
A defect can appear at any stage. The sections below isolate the most common failure points, explain why they arise, and give a repeatable way to catch them before release.
Common Push Notifications Bugs and How to Catch Them: Overview
| Bug ID | Primary Symptom | Typical Root Cause | Detection Lever | Fix Category |
|---|---|---|---|---|
| P1 | No permission dialog shown on first launch | Manifest missing POST_NOTIFICATIONS (Android 13+) or UNUserNotificationCenter not called | Launch‑time UI test, log check for registerForRemoteNotifications | Manifest / code addition |
| P2 | Device receives no notification despite backend 200 | Payload missing notification key or malformed JSON; FCM rejects silently | Backend log inspection, FCM delivery receipt, device logcat for FirebaseMessaging | Payload schema validation |
| P3 | Same notification appears two or more times | Server retries without deduplication; client does not collapse identical collapse_key | Count notifications in shade after a burst, check FCM collapse_key handling | Server‑side idempotency, client collapse key |
| P4 | Notification heads‑up or lock‑screen entry missing | Priority set to low; channel importance mis‑configured; iOS interruptionLevel set to passive | Verify notification appears on lock screen after device lock, check channel importance | Adjust priority/importance, correct iOS interruption level |
| P5 | Tap opens wrong screen or crashes | Intent/extras mismatched; deep‑link URL not validated; service extension returns nil UNNotificationResponse | UI test tapping notification, assert target activity/fragment; crashlytics for EXC_BAD_ACCESS | Validate intent extras, deep‑link parser, extension safety |
| P6 | Notifications stack incorrectly on Android 12+ | Group key missing or mismatched; setGroupAlertBehavior not used | Send two notifications with same group, observe shade grouping | Set setGroup and setGroupAlertBehavior correctly |
| P7 | Delivery delayed >10 min despite immediate send | Background fetch throttled; APNs silent push limited; FCM collapsible message throttled | Measure time between send and onMessageReceived; check device battery optimization whitelist | Use high‑priority FCM, avoid silent pushes, request exemptions for background work |
| P8 | Service extension crashes, causing fallback to raw payload | Unhandled exception in didReceiveNotificationRequest, exceeding time limit | Crashlytics for extension, console log for EXTENSION_BOOM | Wrap extension code in try/catch, keep work < seconds, offload heavy tasks |
| P9 | Token rotation leads to messages sent to stale token | App does not refresh token after onNewToken/didRefreshRegistrationToken; server caches old token | Compare server‑side token DB with fresh device token after app reinstall or OS update | Subscribe to token refresh callbacks, invalidate server cache on mismatch |
| P10 | Garbled characters or missing localization | Payload encoded as UTF‑8 but server sends ISO‑8859‑1; iOS falls back to system locale | Inspect raw payload via proxy (e.g., Charles), check displayed string for mojibake | Enforce UTF‑8 everywhere, add Content‑Type: application/json; charset=utf-8 header |
| P11 | Notifications throttled by OS after bursts >5/min | Android’s NotificationManagerPolicy or iOS’s interruptionLevel + timeSensitive flags cause silent drops | Send burst, monitor shade for missing entries, check adb shell cmd notification stats | Respect OS limits, combine related updates, use setTimeoutAfter for iOS |
| P12 | Accessibility services ignore notification (TalkBack/VoiceOver) | Missing contentDescription or accessibilityTitle; notification lacks setCategory for announcement | Run TalkBack, verify announcement; use Accessibility Scanner for missing descriptors | Add contentDescription, set appropriate notification category, test with accessibility services |
Each bug is explored in depth below.
Bug Pattern 1: Missing Permission Prompt
Why it happens
Android 13 introduced the runtime permission POST_NOTIFICATIONS. If the manifest omits or the app never calls NotificationManagerCompat.from(context).areNotificationsEnabled(), the system silently discards all notification posts. On iOS, forgetting to call UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) yields the same result.
User‑visible symptom
The user never sees a permission dialog; subsequent pushes appear to “do nothing.” In analytics, opt‑in rates drop to zero.
Reproduction steps
- Install a clean build on a device/API 33+ (Android) or iOS 15+.
- Launch the app; do not trigger any permission request manually.
- From your backend, send a test push.
- Observe the shade/lock screen – no notification appears.
- Check logcat for
W/NotificationService: Not posting notification, user has blocked notifications(Android) or console for[UNUserNotificationCenter] Authorization status: notDetermined(iOS).
Detection tactics
- Manual: Launch the app on a fresh device, verify the permission dialog appears within the first 5 seconds.
- Automated UI test (Espresso/AndroidX Test):
@Test
fun permissionDialogAppears() {
// Assume MainActivity launches a PermissionRequestFragment
onView(withId(R.id.permission_grant_button)).check(matches(isDisplayed()))
}
notificationEnabled flag is true (you can expose this via a simple /ping endpoint that returns the flag).Fix and prevention
- Add the permission to
AndroidManifest.xml. - Request it at runtime using
ActivityResultContracts.RequestPermission. - On iOS, invoke
requestAuthorizationinapplication(_:didFinishLaunchingWithOptions:)or on first launch after onboarding. - Guard all
NotificationManager.notifycalls withareNotificationsEnabled()(Android) orUNUserNotificationCenter.current().getNotificationSettings { … }(iOS). - Add a unit test that mocks the permission manager and asserts the request is called.
Bug Pattern 2: Silent Failures Due to Incorrect Payload
Why it happens
FCM expects a top‑level notification object for display messages and/or a data object for silent payloads. If you send only a data payload without setting priority: "high" on Android, the system may treat it as low‑priority and defer delivery. APNs requires the aps dictionary; missing it results in the payload being dropped silently.
User‑visible symptom
Backend logs show HTTP 200 from FCM/APNs, yet the device never receives a notification. No error surfaces in Firebase Console or APNs feedback.
Reproduction steps
- Use
curlto send a malformed payload: - On the device, run
adb logcat | grep FirebaseMessaging(Android) orConsole.appfilterMyApp(iOS). - No
onMessageReceivedcallback fires; no notification appears.
curl -X POST https://fcm.googleapis.com/v1/projects/my-proj/messages:send \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{
"message": {
"token": "<DEVICE_TOKEN>",
"data": {
"score": "5x"
}
}
}'
Detection tactics
- Manual: Keep a terminal window with logcat or Console while you trigger a send from Postman.
- Automated: Write a contract test that validates the JSON schema before sending:
{
"required": ["message"],
"properties": {
"message": {
"required": ["token"],
"properties": {
"token": { "type": "string" },
"notification": { "type": ["object", "null"] },
"data": { "type": "object" }
}
}
}
}
Fail the CI build if the payload does not conform.
Fix and prevention
- Always include a
notificationblock when you want a visible alert, even if you only need to update a badge ("notification": { "title": "", "body": "" }). - For data‑only messages, set Android priority to
"high"and APNsapswith"content-available": 1. - Use server‑side libraries (Firebase Admin SDK, APNs provider) that validate the payload before transmission.
- Add integration test that sends a push to a test device and asserts receipt within a configurable timeout (e.g., 10 seconds).
Bug Pattern 3: Duplicate Notifications
Why it happens
Duplicates arise when the backend retries a failed HTTP request without deduplication, or when the client receives the same FCM message with identical collapse_key but fails to collapse it because the key is missing or null. On iOS, repeated silent pushes can cause multiple UNUserNotificationCenterDelegate callbacks if the app does not ignore duplicates based on a payload UUID.
User‑visible symptom
The shade shows two or more identical entries; tapping one may trigger the same action twice, leading to duplicate state changes (e.g., two items added to a cart).
Reproduction steps
- Configure your push sender to send the same message twice within 2 seconds.
- Ensure the payload contains
"collapse_key": "order_update_123"(Android) orapns-collapse-idheader (iOS). - Watch the notification shade; you should see a single entry.
- Remove the
collapse_keyor set it to an empty string and repeat – you will now see two entries.
Detection tactics
- Manual: Send bursts and visually count.
- Automated UI test (UiAutomator):
@Test
public void noDuplicatesOnCollapseKey() {
sendPush(collapseKey: "test");
sendPush(collapseKey: "test");
Assert.assertEquals(1, getNotificationCount());
}
message_id and reject duplicates within a short window (e.g., 5 seconds).Fix and prevention
- Always set a meaningful
collapse_key(orapns-collapse-id) for updates that should replace previous notifications. - On the client, override
onMessageReceivedand, if you handle display yourself, checkNotificationManager.getActiveNotifications()for an existing notification with the same key before issuing a new one. - Implement idempotency on the backend: store a hash of the payload + device token + timestamp and ignore repeats within a configurable window.
Bug Pattern 4: Notification Not Appearing on Lock Screen
Why it happens
Android channels with IMPORTANCE_LOW or IMPORTANCE_NONE never show heads‑up or lock‑screen entries. iOS treats notifications with interruptionLevel .passive as silent unless the user has enabled “Show on Lock Screen” in Settings → Notifications → Your App.
User‑visible symptom
The user receives a notification only after unlocking the device; they miss time‑sensitive alerts (e.g., OTP).
Reproduction steps
- Send a push with channel importance set to
IMPORTANCE_LOW. - Lock the device immediately after sending.
- Unlock and check the shade – notification appears, but it was not on the lock screen.
- Repeat with
IMPORTANCE_HIGHand verify lock‑screen visibility.
Detection tactics
- Manual: Use
adb shell cmd notification listto see which notifications are flagged assecret(lock‑screen hidden). - Automated: Espresso test that locks the device (
adb shell input keyevent KEYCODE_LOCK), sends a push via FCM test token, then unlocks and asserts that the notification appears in the shade and that the lock‑screen preview is present (checkNotification.getVisibility() == VISIBILITY_PUBLIC).
Fix and prevention
- For time‑sensitive content, create a channel with
IMPORTANCE_HIGH(Android) and setsetLockScreenVisibility(VISIBILITY_PUBLIC). - On iOS, set
UNNotificationInterruptionLevel.timeSensitiveand include theapskey"alert": { "title": "...", "body": "..." }. - Add a unit test that builds the notification object and asserts
importance == IMPORTANCE_HIGHandlockScreenVisibility == VISIBILITY_PUBLIC.
Bug Pattern 5: Tap Action Leads to Wrong Screen or Crash
Why it happens
The notification’s tap intent may contain stale deep‑link parameters, or the PendingIntent may be constructed with FLAG_IMMUTABLE (Android 12+) causing the extras to be stripped. On iOS, the UNNotificationResponse may have a userInfo dictionary missing the expected deep‑link key, leading to a forced‑unwrap crash.
User‑visible symptom
Tapping the notification opens the home screen, a generic splash, or crashes the app with an exception like NullPointerException: Attempt to invoke virtual method 'java.lang.String android.os.Bundle.getString(java.lang.String)' on a null object reference.
Reproduction steps
- Send a notification with payload:
- Ensure the
PendingIntentis built withgetActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE). - Tap the notification; observe that the app launches
MainActivitywithout readingitemId. - Change the flag to
FLAG_MUTABLE(if targeting SDK 31+) and repeat – the extras survive.
{
"notification": { "title": "Promo", "body": "20% off" },
"data": { "screen": "promo", "itemId": "42" }
}
Detection tactics
- Manual: Use
adb shell dumpsys notificationto inspect the pending intent extras before tapping. - Automated UI test:
@Test
fun notificationTapNavigatesCorrectly() {
sendPush(data = mapOf("screen" to "promo", "itemId" to "99"))
onView(withId(R.id.notification)).perform(click())
// Assert we are on PromoFragment and itemId is 99
onView(withId(R.id.itemIdText)).check(matches(withText("99")))
}
NULL_POINTER_EXCEPTION in onNewIntent or handleDeepLink.Fix and prevention
- Use
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLEfor Android 12+ when you need to modify extras later. - Keep a canonical deep‑link builder that validates required parameters before constructing the intent.
- On iOS, always read
response.notification.request.content.userInfowith safe casting (if let itemId = userInfo["itemId"] as? String { … }). - Add a unit test that builds the pending intent and asserts the presence of each expected extra.
Bug Pattern 6: Notification Grouping Issues on Android 12+
Why it happens
Android 12 introduced stricter grouping rules: notifications without a setGroup key are considered ungrouped, while those with a mismatched groupKey appear as separate stacks. Additionally, setGroupAlertBehavior controls whether children generate heads‑up alerts.
User‑visible symptom
A burst of chat messages appears as many individual entries instead of a single collapsed group, causing shade clutter and missed summaries.
Reproduction steps
- Send three messages in quick succession with the same
groupKey:"chat_convo_42". - Omit
setGroupAlertBehavior(GROUP_ALERT_SUMMARY)on the first posting. - Observe the shade: three separate notifications, no summary.
- Add the group alert behavior and repeat – you now see a single stacked notification with a summary line.
Detection tactics
- Manual: Use
adb shell cmd notification listand look forgroupKeyvalues. - Automated: UiAutomator test that sends three pushes, then asserts
getActiveNotifications().stream().filter(n -> n.getGroup().equals("chat_convo_42")).count() == 1.
Fix and prevention
- Always set a deterministic
groupKeyfor related notifications (e.g., conversation ID, workflow ID). - Call
setGroupAlertBehavior(GROUP_ALERT_SUMMARY)on the first notification in the group; subsequent posts can useGROUP_ALERT_CHILDREN. - For iOS, use
UNNotificationCategorywithidentiferandactionsto enable grouping; test withUNUserNotificationCenter.current().getNotificationCategories { … }.
Bug Pattern 7: Background Fetch Limits Causing Delayed Delivery
Why it happens
Both platforms throttle background work to preserve battery. FCM treats low‑priority messages as “delayable” if the app is in a restricted background state (e.g., battery optimization, Doze mode). APNs may silently discard pushes marked content-available if the app has exceeded its background execution quota.
User‑visible symptom
A user expects an instant update (e.g., live score) but sees it only after opening the app or after a significant delay (several minutes).
Reproduction steps
- Enable battery optimization for the app (
Settings → Apps → YourApp → Battery → Optimize battery usage). - Send a low‑priority FCM message (
"priority": "normal"). - Record the time between send and
onMessageReceived. - Disable optimization and repeat – delivery time drops from >30 s to <2 s.
Detection tactics
- Manual: Use
adb shell cmd jobscheduler listto see if your job is deferred. - Automated: Espresso test that toggles
setIgnoreBatteryOptimizations(true)via ADB, sends a push, and measures latency usingSystemClock.elapsedRealtime()in a test listener.
Fix and prevention
- For truly time‑sensitive data, use high‑priority FCM (
"priority": "high"). On iOS, mark the push with"aps": { "content-available": 1, "alert": { ... } }and setinterruptionLevel .timeSensitive. - If you must use low‑priority, combine it with a foreground service start via a visible notification (allowed for a short window) to temporarily lift restrictions.
- Respect platform quotas: batch silent updates and use exponential backoff if you receive a
429from FCM. - Add a test that verifies high‑priority messages arrive within a configurable SLA (e.g., 5 seconds) even when battery optimization is enabled.
Bug Pattern 8: iOS Notification Service Extension Crashes
Why it happens
The Notification Service Extension has a strict runtime limit (≈ seconds) and limited memory. Throwing an uncaught exception or performing synchronous network calls can cause the system to terminate the extension and fall back to delivering the raw payload, which may lack custom UI modifications (e.g., image attachment, modified title).
User‑visible symptom
Users see a notification with the original title/body instead of the expected enriched version (e.g., no inline image, missing action buttons). In some cases, the app crashes shortly after tapping because the extension’s failure left shared state inconsistent.
Reproduction steps
- In the extension’s
didReceiveNotificationRequest, force a crash: - Send a push with a
mutable-content: 1flag and an attachment URL. - Observe that the notification appears without the attachment and that the console logs
EXTENSION_BOOM.
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
let badArray = [Int]()
_ = badArray[10] // triggers EXC_BAD_INSTRUCTION
self.contentHandler(request.content)
}
Detection tactics
- Manual: Watch Xcode console for
EXTENSION_BOOMor check Crashlytics for crashes in the extension bundle. - Automated: Unit test the extension target with a mock
UNNotificationRequest; assert that no exception is thrown and that the returnedUNNotificationContentcontains the expected modifications.
Fix and prevention
- Wrap the entire extension entry point in a
do { … } catch { … }block; on error, return the original content unchanged. - Offload any network or heavy processing to a background thread with a timeout; if the timeout fires, return the original content.
- Keep the extension’s memory footprint under 50 MB; use
NSURLSessionwithconfiguration = .backgroundif you must fetch assets. - Add a CI step that builds the extension target and runs the unit test suite; fail the build on any test that throws.
Bug Pattern 9: Firebase Cloud Messaging Token Rotation Issues
Why it happens
FCM tokens can change when: the app is restored on a new device, the user reinstalls the app, clears app data, or the system refreshes security tokens. If your server continues to push to the stale token, FCM returns a NotRegistered error, which you might ignore, leading to silent message loss.
User‑visible symptom
A subset of users (often those who recently updated or reinstalled) stops receiving pushes altogether, while others receive them normally.
Reproduction steps
- Obtain a fresh FCM token on a test device.
- Send a push – verify receipt.
- Simulate token rotation by calling
FirebaseMessaging.getInstance().deleteToken()(or clearing app data). - Obtain the new token; note that it differs from the old one.
- Continue sending pushes using the old token; observe that FCM returns
NotRegisteredin the response body.
Detection tactics
- Manual: Monitor your push sender logs for
NotRegisterederrors and correlate with device IDs. - Automated: After each token refresh callback (
onNewTokenon Android,didRefreshRegistrationTokenon iOS), POST the new token to your server and assert that the server updates its record within a deterministic time window (e.g., < 5 seconds).
Fix and prevention
- Always listen for token refresh callbacks and promptly upload the new token to your backend.
- On the server, treat any
NotRegisteredorInvalidRegistrationresponse as a signal to delete or invalidate that token immediately. - Implement a token version number or timestamp; if the server receives a token older than the stored version, request a refresh from the client.
- Add a synthetic test that forces token deletion and verifies that the server stops sending to the old token after the next refresh cycle.
Bug Pattern 10: Localization and Encoding Problems
Why it happens
Push payloads often travel through multiple services (API gateway, queue, worker). If any service defaults to ISO‑8859‑1 or fails to declare UTF‑8, multilingual characters (e.g., emojis, accented letters) become garbled or appear as replacement symbols.
User‑visible symptom
Users see “???” or garbled text in the notification title/body, reducing trust and causing confusion (especially for OTPs containing non‑ASCII digits).
Reproduction steps
- Craft a push with payload:
- Send it via a middleware that incorrectly sets
Content-Type: text/plain. - On the device, inspect the notification; the title shows
¡Oferta!and the body shows garbled emojis.
{ "title": "¡Oferta!", "body": "🎉 20% de descuento" }
Detection tactics
- Manual: Use a network proxy (Charles, mitmproxy) to view the raw HTTP body; confirm UTF‑8 encoding.
- Automated: In your integration test, after sending the push, retrieve the raw payload from the device via
adb shell dumpsys notification(Android) or extract the deliveredUNNotificationContenton iOS and assert thattitle.contains("¡Oferta!")andbody.contains("🎉").
Fix and prevention
- Enforce
Content-Type: application/json; charset=utf-8on every HTTP endpoint that handles push payloads. - Use language‑agnostic libraries that automatically encode JSON as UTF‑8 (e.g.,
Jackson,Gson,System.Text.Json). - Add a schema validation step that rejects any payload containing non‑UTF‑8 bytes.
- Include a localization test suite that sends a set of representative strings (right‑to‑left, CJK, emojis) and verifies correct rendering on both platforms.
Bug Pattern 11: Over‑aggressive Rate Limiting by OS
Why it happens
Both Android and iOS apply rate limiting to prevent notification spam. Android may collapse or silently drop notifications if the same app posts more than a threshold (e.g., 5 notifications per second) unless they use distinct notificationIds or are marked as ongoing. iOS groups notifications and may suppress further alerts if the user has not interacted with recent ones.
User‑visible symptom
During a burst of events (e.g., live match updates), the user sees only the first few notifications; later updates appear to be “missing.”
Reproduction steps
- Send 10 notifications in rapid succession (interval < 200 ms) with the same
notificationId. - Observe the shade: only the first 1‑2 appear; the rest are absent.
- Change each notification to use a unique
notificationId(e.g., timestamp) and repeat – all appear.
Detection tactics
- Manual: Use
adb shell cmd notification statsto seepostedvsdroppedcounts. - Automated: Espresso test that sends a burst with a counter in the
notificationId, then asserts thatgetActiveNotifications().size() == expectedCount.
Fix and prevention
- For high‑frequency updates, consider using a single ongoing notification that you update via
notify(id, updatedNotification)instead of posting new ones. - If distinct notifications are required, ensure each has a unique ID and, if appropriate, set
setOnlyAlertOnce(true)to reduce interruptiveness. - On iOS, combine related updates into a single notification with a mutable
summarystring that you modify via a background session. - Add a load test that simulates peak event rates and verifies that no more than X % of notifications are dropped (define X based on product requirements).
Bug Pattern 12: Accessibility Failures (TalkBack/VoiceOver)
Why it happens
Accessibility services rely on the notification’s contentDescription (Android) or accessibilityLabel (iOS) to announce the event. If you only set the visible title/body and neglect these fields, TalkBack may read the package name or say “Notification, no description.”
User‑visible symptom
Users with visual impairments miss critical information (e.g., two‑factor authentication codes) because the screen reader either says nothing useful or reads irrelevant metadata.
Reproduction steps
- Send a notification with title “Your code is 123456”.
- Enable TalkBack (
Settings → Accessibility → TalkBack). - Lock the device, then unlock to trigger the announcement.
- Listen – TalkBack may say “Notification from com.example.app” without reading the code.
Detection tactics
- Manual: Use TalkBack and verify that the spoken feedback matches the visible text.
- Automated: UI Automator test that enables accessibility service, sends a push, then captures the spoken output via
AccessibilityEventand asserts that the expected string is present.
Fix and prevention
- Always set
contentDescriptionon Android:
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setContentDescription("$title: $body")
.build()
userInfo["accessibilityLabel"] or modify the UNMutableNotificationContent’s title and body;Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free