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.