Back to articles

Performance & Security

Why Your Push Notifications Work on Pixel and Fail on Xiaomi — FCM Across OEMs

Here’s a support ticket every messaging-app team has received: “I don’t get message notifications until I open the app. Then they all arrive at once. My friend on an iPhone gets them instantly. Your app is broken.” The user is on a Xiaomi. Or a Huawei. Or a Samsung with the wrong setting. The app isn’t broken — FCM delivered the message to the device, and the OEM’s power management held it in a queue until the user opened the app, defeating the entire point of a push notification.

Push notification reliability is the single most common cross-OEM complaint, and it’s maddening because it works perfectly on the Pixel you develop on. FCM — Firebase Cloud Messaging — is rock solid on stock Android. On OEM-modified Android, the same FCM message can arrive instantly, arrive minutes late, arrive batched with others, or not arrive until the app is opened. The cause is the same vendor process-killing covered in the Battery Optimization post, manifesting in the notification pipeline instead of the foreground-service one.

This post traces the FCM delivery pipeline layer by layer to find exactly where each OEM breaks it, what you can do at each layer, the difference between notification messages and data messages (which matters more than most developers realize), the high-priority escape hatch and its costs, and the realistic SLA you can promise across the device fleet. Examples come from a messaging app because delayed message notifications are the most visceral version of this problem. This is the second post in the OEM Fragmentation cluster.


The FCM Delivery Pipeline — Where Things Break

To understand where OEMs break delivery, you need the full path a notification travels:

1. Your server → FCM backend (HTTP v1 API call with the message)
2. FCM backend → device (via a persistent connection Google Play Services
   maintains to FCM servers)
3. Google Play Services on device → receives the message
4. Play Services → wakes/delivers to your app’s FirebaseMessagingService
5. Your onMessageReceived() → builds and posts a Notification
6. Android notification system → displays it
7. User sees it

On a Pixel, every step happens within seconds. The breakages by OEM happen at specific steps:

Step 2–3 (Play Services connection): on devices without Google Play Services (Huawei post-2019), this connection doesn’t exist. FCM cannot deliver at all. You need an alternative push channel entirely.

Step 4 (Play Services wakes your app): this is where most OEM breakage happens. The message reached Play Services, but the OEM’s power manager won’t let Play Services wake your (background-restricted, “sleeping,” or auto-start-denied) app. The message sits in a queue.

Step 5–6 (your service posts notification): if your app does get woken but does heavy work in onMessageReceived, the OEM may kill it before it finishes posting the notification.

The insight: FCM almost always succeeds in getting the message to the device (steps 1–3). The failure is in the device handing it to your app (step 4). This is why “is FCM down?” is the wrong question — FCM is fine; the OEM is gating delivery to your app.


Notification Messages vs. Data Messages — The Distinction That Matters

FCM has two message types, and the difference is critical for OEM behavior.

Notification messages contain a notification payload. When the app is in the background, the system (Play Services) displays the notification directly — your app’s code doesn’t run. Your onMessageReceived is only called if the app is in the foreground.

// A notification message (server-side payload)
{
  “message”: {
    “token”: “device_token”,
    “notification”: {
      “title”: “New message from Alex”,
      “body”: “Are we still on for lunch?”
    }
  }
}
// Background: Play Services shows this directly. Your code does NOT run.
// Foreground: onMessageReceived() is called.

Data messages contain only a data payload. Your onMessageReceived is always called (foreground or background), and your code builds the notification.

// A data message (server-side payload)
{
  “message”: {
    “token”: “device_token”,
    “data”: {
      “type”: “new_message”,
      “sender”: “Alex”,
      “preview”: “Are we still on for lunch?”,
      “conversation_id”: “conv_123”
    }
  }
}
// Both foreground AND background: onMessageReceived() is called.
// YOUR code builds the notification.

Why this matters for OEMs: data messages require your app to be woken to run code (step 4), which is exactly what OEM power management blocks. Notification messages are displayed by Play Services itself, which OEMs are less aggressive about blocking (Play Services is a system app they don’t kill).

The practical implication, counterintuitive to many developers: for maximum delivery reliability on hostile OEMs, prefer notification messages (or the combined notification+data form) over pure data messages. A pure data message that needs your app woken is more likely to be delayed on a Xiaomi than a notification message that Play Services displays directly.

The combined form — both notification and data — gives you the system-displayed notification (reliable) plus data for when the user taps it (deep-linking to the right conversation). For a messaging app, this is usually the right choice:

{
  “message”: {
    “token”: “device_token”,
    “notification”: { “title”: “Alex”, “body”: “Lunch?” },
    “data”: { “conversation_id”: “conv_123” },
    “android”: {
      “priority”: “high”
    }
  }
}

The trade-off: with notification messages, you lose some control (can’t customize the notification as freely when the app is backgrounded, can’t silently process data without showing anything). For a messaging app where you always want to show the notification anyway, that’s an acceptable trade. For an app that needs silent background data sync, you’re forced into data messages and must fight the OEM delivery problem directly.


Message Priority — The High-Priority Escape Hatch

FCM messages have a priority: normal or high. This directly affects OEM behavior.

Normal priority: the OS may batch and delay these to save power. During Doze, they wait for a maintenance window. On aggressive OEMs, “may delay” becomes “will delay until the user opens the app.”

High priority: signals to the OS that this message should wake the device and deliver immediately, even during Doze. This is the escape hatch for time-sensitive notifications (messages, calls, alerts).

“android”: {
  “priority”: “high”
  // This is the difference between “arrives instantly” and
  // “arrives whenever the OS feels like it” on most devices
}

But high priority has costs and limits:

1. Quota. Google monitors high-priority message usage. Apps that abuse it (sending everything as high-priority when it isn’t time-sensitive) can have their high-priority messages downgraded. Use high-priority only for genuinely time-sensitive content — a chat message, yes; a “you have a new recommendation” nudge, no.

2. App Standby Buckets. Android assigns apps to buckets (Active, Working Set, Frequent, Rare, Restricted) based on usage. Apps in the Rare or Restricted bucket have high-priority FCM quotas limited. An app the user rarely opens gets fewer guaranteed high-priority deliveries — a chicken-and-egg problem (they don’t open it because notifications are unreliable; notifications are unreliable because they don’t open it).

3. OEMs can still interfere. High priority improves delivery odds dramatically but doesn’t fully override an OEM that has the app in a hard “deep sleep” / “restricted” state. It’s necessary but not always sufficient.

The rule: send time-sensitive notifications as high-priority, everything else as normal. This both improves reliability for what matters and protects your high-priority quota from being throttled for abuse.


The Per-OEM Reality (Again)

The same vendors from the Battery Optimization post, now in the notification context:

Samsung (OneUI). Generally the least hostile of the major non-Google OEMs for notifications. High-priority FCM mostly works. The killer is the “Sleeping Apps” / “Deep Sleeping Apps” list — an app in Deep Sleep won’t receive notifications reliably until reopened. The fix is the same battery-optimization-exemption flow from the OEM Battery post.

Xiaomi (MIUI/HyperOS). One of the worst. Even high-priority FCM is delayed or dropped if the app lacks Autostart permission and isn’t set to “no battery restrictions.” MIUI also has a separate per-app “Notifications” setting and a “Show on lock screen” toggle that affect visibility. The user has to grant Autostart specifically for FCM delivery to be reliable.

Huawei (EMUI/HarmonyOS, post-2019). No Google Play Services on newer devices means FCM doesn’t work at all. You need HMS Push Kit as an alternative. Even on older Huawei devices with GMS, the Protected Apps list gates delivery.

OPPO / Vivo / OnePlus / Realme (ColorOS family). Similar to MIUI — auto-start and background-activity permissions gate FCM delivery. Default settings delay notifications; the user must grant the permissions.

Pixel / Android One / stock. FCM works as documented. High-priority delivers promptly even in Doze. This is your baseline — and the reason the bug never reproduces on your dev device.


Alternative Push Channels for Non-GMS Devices

For Huawei (and Chinese-market devices generally), FCM is unavailable. The serious options:

HMS Push Kit (Huawei). Huawei’s equivalent of FCM. If you target markets with significant Huawei share, integrate it alongside FCM and pick at runtime.

// Runtime selection of push provider
suspend fun registerForPush(): PushRegistration = when {
    isGmsAvailable() -> {
        val token = FirebaseMessaging.getInstance().token.await()
        PushRegistration(provider = “fcm”, token = token)
    }
    isHmsAvailable() -> {
        val token = HmsInstanceId.getInstance(context)
            .getToken(appId, “HCM”)
        PushRegistration(provider = “hms”, token = token)
    }
    else -> {
        // Some Chinese OEMs: vendor-specific push (Xiaomi Push, OPPO Push, etc.)
        // or fall back to a self-hosted persistent socket (expensive, last resort)
        registerVendorOrFallbackPush()
    }
}

Your server needs to track which provider each device uses and route through the right one. A device registered via HMS gets messages via Huawei’s push servers; an FCM device via Google’s. The server abstracts “send notification to user X” over whichever provider their device registered with.

Vendor-specific push (Xiaomi Push, OPPO Push, Meizu Push). In the Chinese market, each OEM has its own push service that’s more reliable on their devices than FCM (which is often unavailable anyway). Apps serious about the Chinese market integrate a unified push aggregator that routes through the right vendor service per device. This is significant additional engineering, justified only if those markets matter to your business.

Self-hosted persistent connection (last resort). A long-lived socket your app maintains to your own server. Gives you full control but fights the same OEM battery management that kills everything else — the socket gets killed when the app is backgrounded on hostile OEMs. Generally not worth it versus using the platform push services; only relevant for niche cases.


What Your App Code Should Do

The FirebaseMessagingService implementation, with the OEM-aware best practices:

class AppMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(message: RemoteMessage) {
        // Keep this FAST. On hostile OEMs, you may have very little time
        // before the app is killed again. Do NOT do heavy work here.

        // ❌ Don’t: hit the network, do DB writes, sync data
        // ✅ Do: build and post the notification immediately

        val conversationId = message.data[“conversation_id”]
        val sender = message.data[“sender”] ?: message.notification?.title
        val preview = message.data[“preview”] ?: message.notification?.body

        showMessageNotification(sender, preview, conversationId)

        // If you need to sync data, enqueue WorkManager — don’t do it inline
        if (message.data[“needs_sync”] == “true”) {
            WorkManager.getInstance(this).enqueue(
                OneTimeWorkRequestBuilder<SyncWorker>().build()
            )
        }
    }

    override fun onNewToken(token: String) {
        // Token can change (app reinstall, data cleared, Play Services update)
        // Send the new token to your server immediately
        // Use WorkManager so this survives if the app is killed mid-registration
        WorkManager.getInstance(this).enqueue(
            OneTimeWorkRequestBuilder<TokenRegistrationWorker>()
                .setInputData(workDataOf(“token” to token))
                .build()
        )
    }

    private fun showMessageNotification(
        sender: String?,
        preview: String?,
        conversationId: String?
    ) {
        val channelId = “messages”
        // Notification channels are mandatory since Android 8.
        // Create the channel once (in Application.onCreate or first use).

        val intent = Intent(this, MainActivity::class.java).apply {
            putExtra(“conversation_id”, conversationId)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        val pendingIntent = PendingIntent.getActivity(
            this, conversationId.hashCode(), intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
        )

        val notification = NotificationCompat.Builder(this, channelId)
            .setContentTitle(sender)
            .setContentText(preview)
            .setSmallIcon(R.drawable.ic_notification)
            .setPriority(NotificationCompat.PRIORITY_HIGH)  // Heads-up display
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .build()

        NotificationManagerCompat.from(this).notify(
            conversationId.hashCode(),  // Stable ID so updates replace, not stack
            notification
        )
    }
}

Three OEM-specific best practices baked in:

1. onMessageReceived is fast. Heavy work here risks the OEM killing the app before the notification posts. Build and post immediately; defer everything else to WorkManager.

2. onNewToken registers via WorkManager. If the token-registration network call dies because the app gets killed, WorkManager retries. A device with an unregistered token gets zero notifications — this is a silent, severe failure mode worth defending against.

3. setPriority(PRIORITY_HIGH) + CATEGORY_MESSAGE. This is the notification-channel priority (distinct from FCM message priority). It controls heads-up display. Both priorities matter: FCM high-priority gets the message delivered promptly; notification high-priority gets it shown prominently.


The POST_NOTIFICATIONS Permission (Android 13+)

Since Android 13, notifications require a runtime permission. If you don’t request it, your perfectly-delivered FCM messages produce zero visible notifications — another silent failure.

@Composable
fun NotificationPermissionRequest() {
    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (!granted) {
            // User denied. Notifications won’t show. Consider a soft-ask
            // explaining why before re-prompting, or guide to settings.
        }
    }

    LaunchedEffect(Unit) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }
}

Best practice: don’t request it cold on first launch. Use a soft-ask (explain the value first), request at a contextual moment (“turn on notifications so you don’t miss messages”), and handle denial gracefully. A denied permission is permanent-ish (you can’t re-prompt repeatedly), so the first ask matters.


Diagnosing Delivery Problems

When a user reports missing notifications, the diagnostic flow:

1. Is the token registered? Check your server — does this user/device have a current FCM token? If not, the onNewToken registration failed (often an OEM-killed-the-app issue). No token = no delivery, period.

2. Did FCM accept the message? The FCM HTTP v1 API returns success/failure per message. Log these. If FCM rejected it (invalid token, etc.), the problem is server-side.

3. Did FCM report delivery? FCM has delivery reporting (via the Firebase console and BigQuery export) showing delivered vs. dropped counts, segmented by app version and time. A high drop rate on specific device types points at OEM interference.

4. Is the device in a restricted state? If you can get the user to check, the OEM’s battery/autostart settings for your app reveal whether it’s being throttled. This is where you guide them through the OEM-specific settings (same as the Battery Optimization post).

5. Is the notification permission granted? On Android 13+, an ungranted permission means delivered-but-invisible.

The telemetry that makes this tractable: log token-registration success/failure, FCM send results, and (where possible) notification-displayed events, all segmented by manufacturer and Android version. Within a week of release you’ll see which OEM/version combinations have the worst delivery rates — that’s where to focus the user-education and high-priority-message efforts.


The Realistic SLA You Can Promise

Honesty about what’s achievable:

Pixel / stock Android: high-priority FCM delivers within seconds, reliably. You can promise instant notifications.

Samsung (with battery optimization exemption granted): near-instant for high-priority. Without the exemption and if the app deep-sleeps, delays of minutes to “until reopened.”

Xiaomi / OPPO / Vivo (with autostart + no-restrictions granted): mostly reliable. Without those permissions, unreliable to the point of “basically broken.” The user-permission step is mandatory, not optional, for these devices.

Huawei (no GMS): FCM doesn’t work; HMS Push Kit required, with its own reliability characteristics. Without HMS integration, zero notifications.

The realistic promise to make to your users: “instant notifications, after you grant the permissions we’ll guide you through.” The onboarding that secures battery-optimization exemption and autostart permission (from the Battery Optimization post) is the same onboarding that makes notifications reliable. They’re the same underlying fight.


Pitfalls Worth Calling Out

Pure data messages for time-sensitive notifications. Data messages need your app woken — the exact thing OEMs block. For reliability, prefer notification or notification+data messages.

Normal priority for time-sensitive content. Normal-priority messages get batched and delayed. Use high priority for messages, calls, and alerts.

Heavy work in onMessageReceived. Network calls and DB writes risk the OEM killing the app before the notification posts. Post the notification first, defer the rest to WorkManager.

Not handling onNewToken robustly. A token-registration failure means total notification silence for that device. Register via WorkManager so it retries.

Forgetting POST_NOTIFICATIONS on Android 13+. Delivered-but-invisible. Request the permission with a contextual soft-ask.

Assuming FCM works everywhere. Huawei post-2019 has no GMS. If those markets matter, integrate HMS or vendor push.

Blaming FCM for OEM problems. The message almost always reaches the device; the OEM gates handoff to your app. Debugging FCM when the problem is OEM power management wastes time.

Cargo-culting high-priority everywhere. Marking every message high-priority risks Google throttling your high-priority quota. Reserve it for genuinely time-sensitive content.


Closing

Push notification reliability across OEMs is the same fight as the foreground-service survival from the Battery Optimization post, fought in a different arena: the vendor’s power management gates whether your app gets to act on a delivered message. FCM does its job; the device decides whether to wake your app.

The levers you control: prefer notification (or notification+data) messages over pure data messages for reliability, use high priority for time-sensitive content and normal for the rest, keep onMessageReceived fast, register tokens robustly via WorkManager, request POST_NOTIFICATIONS properly, integrate HMS or vendor push for non-GMS markets, and — the big one — guide users through the OEM battery and autostart settings that gate delivery. That last step is shared with the Battery Optimization post because it’s the same root cause.

The realistic SLA: instant on stock Android, instant on OEMs once the user grants the permissions, unreliable until they do. Communicate that honestly, build the onboarding that secures the permissions, and instrument delivery so you know which devices are suffering.

That’s the OEM Fragmentation cluster at two posts: battery optimization killing background work, and OEM power management gating push delivery. A future post will cover foreground-service death patterns specifically, and the broader testing strategy for the long tail of devices. Next on the runway, though, we return to the Maps cluster for marker clustering — rendering thousands of points without melting the device.

Happy coding!

4 views · 0 comments

Comments (0)

No comments yet. Be the first to share your thoughts.