01 · Payload (JSON)
02 · Preview
9:41 ●●● 5G ▮▮▮▯
9:41
Tuesday, May 12
Sample payloads · tap to load

Anatomy of an FCM payload

Firebase Cloud Messaging takes a JSON envelope and turns it into a notification on the user's device. The envelope has three layers that interact in non-obvious ways, and most "why isn't my notification showing up correctly" debugging boils down to misunderstanding which layer overrides which.

The three blocks

message.notification is the cross-platform payload. Whatever you put in it renders on iOS, Android, and Web with their respective default styling. It's the fallback. If you only set this and nothing else, FCM displays the notification automatically and your app's code never runs until the user actually taps it.

message.android.notification is where Android-specific overrides live: title, body, icon, color, channel, priority, image, count, ticker, visibility, sound, vibration patterns, light settings, and a few more. None of that has an iOS equivalent. Fields in this block override whatever was in message.notification, which is useful. You can send one cross-platform title and a different Android-specific title in the same payload. Most teams figure this out after the third time they wonder why their Android title doesn't match the spec.

message.data is opaque key-value pairs that get delivered to your app as intent extras. The user never sees them. Your app reads them in FirebaseMessagingService.onMessageReceived (foreground, or when there's no notification block) or via intent.getExtras() when the user taps the notification (background). One thing the docs bury: every value in this block must be a string. FCM rejects the entire send if you pass an integer or boolean, with an error message that doesn't tell you which field is wrong.

Notification messages vs data messages

A payload with only a data block, no notification, is called a "data message." FCM never displays anything on its own. Your app gets onMessageReceived and decides what to do: build a notification yourself, sync data silently, both. This is the only way to handle a push when you want full control of what the user sees, including when the app is killed.

A payload with a notification block is called a "notification message." If your app is in the foreground when it arrives, you still get onMessageReceived, same as before. If the app is in the background or killed, FCM auto-displays the notification and your code only runs when the user taps. This difference catches every Android developer at least once. Your debug build works fine because you've been testing in the foreground. Then it ships and your custom handling silently doesn't run in production. The pattern of "test in background with the app fully killed" is the single most important thing to add to your notification QA checklist.

Notification priority and channels

Since Android 8.0 (Oreo, 2017), every notification has to belong to a channel, and the channel is what actually controls the user-facing behavior, not the payload. Sound, vibration, lockscreen visibility, do-not-disturb bypass, popup behavior, all of it lives on the channel. Your app creates the channel once at install time and Android remembers it forever afterward, even across app updates. Channel settings always win over payload settings. If you created a channel with IMPORTANCE_LOW and you send a payload marked "notification_priority": "PRIORITY_HIGH", the notification still arrives silently with no heads-up banner. There is no server-side escalation. This is deliberate. Google's position is that the user controls their notification experience, and the channel is the abstraction that gives them that control.

The practical implication: pick channel importance carefully at app install time, because users can change channel settings later but you cannot. For a chat app, the default channel should be IMPORTANCE_HIGH. For marketing or promotional pushes, IMPORTANCE_DEFAULT at most, and probably you want a separate channel anyway so users can disable those without disabling your chat notifications. Anything that should make a sound or pop up has to be IMPORTANCE_HIGH.

The two priority fields in FCM are confusingly named. message.android.priority controls FCM's delivery priority. HIGH means "deliver now, wake the device if necessary." NORMAL means "deliver when convenient, may be batched." For anything interactive, use HIGH. You pay for the bypass with battery, but a chat notification that arrives ten minutes late is worse than useless. The other one, message.android.notification.notification_priority, is a hint for the notification's display priority within its channel, with values PRIORITY_MIN through PRIORITY_MAX. As mentioned, the channel's importance overrides whatever you set here, so this field is mostly cosmetic in practice.

Why your notification looks different in production

Notifications that worked perfectly in your debug build can behave very differently once you ship. There are a handful of reasons, and they're worth knowing in advance because debugging any of them after the fact is painful.

The first is Doze mode and App Standby Buckets. Android aggressively delays notifications for apps the user hasn't opened recently, where "recently" can mean a few days. Your test device opens your app several times a day during development. Real users may go a week without opening it. The only escape is "priority": "HIGH" in the android block, which bypasses Doze for high-priority deliveries. Without it, a notification can arrive minutes or hours after you sent it.

The second is vendor-specific restrictions, and they're arguably worse. Xiaomi, Huawei, Vivo, Oppo, and Samsung all layer their own battery management on top of Android's, and most of them silently kill or delay notifications for apps the user hasn't whitelisted in some opaque settings menu. Test on at least one Chinese OEM device before shipping if any portion of your audience uses one. You'll be surprised what doesn't work.

The third is the notification-trampoline restriction. Since Android 12, you can't launch an Activity from a BroadcastReceiver or Service in response to a notification tap. The PendingIntent attached to the notification has to launch the Activity directly. A lot of deep-link-from-notification code that worked fine on Android 11 just stopped working on 12, often without anyone noticing for weeks because the failure mode is silent. The user taps, nothing happens.

The fourth, more recent, is the POST_NOTIFICATIONS runtime permission. Since Android 13 (API 33), apps targeting API 33+ have to request permission to post notifications at runtime. If you don't request it and the user doesn't grant it, your notifications simply don't show. No error, no callback. Your debug build may have the permission from when you first installed it years ago and clicked through the prompt without remembering. New users on a fresh install won't, unless your app actually asks.

Specific payload mistakes I keep seeing

Sending a data-only payload when you meant to send a notification is the classic. The payload arrives, your app handles it in the foreground because you tested in the foreground, and you assume everything's fine. Then a user with the app killed gets nothing, because there's no notification block for FCM to auto-display and your app can't run to build one. The fix is usually to include both. A notification block for FCM to display, and a data block with metadata your app uses when the user taps.

Non-string values in data get the entire send rejected. This catches people because the FCM error response is unhelpful. It'll tell you "INVALID_ARGUMENT" without naming the field. Convert everything to strings server-side, including booleans ("true" / "false") and numbers ("42"), and parse on the client.

Forgetting to specify channel_id in the payload causes FCM to fall back to your app's default channel, which may have settings you didn't intend: silent, low importance, no heads-up. Always specify channel_id explicitly. If the channel doesn't exist on the device (because the app version that defined it hasn't shipped yet), the notification arrives but uses fallback behavior, which is another quiet failure mode.

Using "high" instead of PRIORITY_HIGH for notification_priority looks innocent and is wrong. The values are the exact strings with the PRIORITY_ prefix and uppercase. The HTTP v1 API accepts unknown string values silently in some cases. Your notification then gets the default priority and you can't tell why.

Image URLs that aren't HTTPS or are too large fail in different ways. HTTPS-only is hard-enforced. The size limit isn't documented precisely, but practical experience suggests big-picture-style notification images need to be under roughly 1MB and close to 2:1 aspect ratio. Larger images get silently downsampled, or dropped entirely, depending on the device.

Testing in seven shapes

The minimum to verify before shipping a notification change: app in foreground, app in background with the screen on, and app killed with the device locked. Those three are the user-visible paths, and the preview tool above covers them visually. The next four require an actual device. Test the same thing with the device in Doze mode after several hours idle, on a vendor-restricted device (Xiaomi or Huawei is usually enough), with POST_NOTIFICATIONS permission granted vs. not, and with the "tap notification to deep-link" flow that takes the user to specific content. The preview catches enough early issues that you spend fewer round trips on the device, but the device is non-negotiable for the rest.

By Belchior · Last updated · May 2026

Copied