Section 3 · Firebase Cloud Messaging
Server-to-device push — token management, message types, Doze bypass, and handling messages in all app states
What FCM Is
Firebase Cloud Messaging (FCM) is Google’s cross-platform messaging solution for delivering push notifications and data payloads from a server to Android devices. FCM maintains a persistent connection between the device and Google’s infrastructure — your server sends a message to FCM, and FCM delivers it to the target device or devices.
FCM is the standard push mechanism for Android. It is also the only reliable way to wake an app from Doze using a high-priority message, because the FCM connection is maintained by Google Play Services outside your app’s process.
Firebase Docs — Firebase Cloud Messaging
Dependency
1
2
3
4
5
// build.gradle.kts (app)
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
}
The Firebase BOM manages all Firebase library versions consistently — you do not specify individual versions when using the BOM.
Registration Token
Every app installation on every device has a unique FCM registration token. Your server needs this token to address messages to a specific device. Tokens can change (app re-install, app data cleared, Play Services update), so your app must handle token refresh events.
Getting the Initial Token
1
2
3
4
5
6
7
8
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "Failed to get FCM token", task.exception)
return@addOnCompleteListener
}
val token = task.result
sendTokenToServer(token) // register with your backend
}
Listening for Token Refresh
Override onNewToken in your FirebaseMessagingService:
1
2
3
4
5
6
7
8
9
10
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
// Token has changed — update your server immediately
// This fires on: fresh install, app re-install, app data cleared,
// Play Services update, device restore
sendTokenToServer(token)
}
}
1
2
3
4
5
6
7
8
<!-- AndroidManifest.xml -->
<service
android:name=".MyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
Token Lifecycle Events
| Event | Result |
|---|---|
| First app install | New token generated |
| App re-installed | New token generated |
| App data cleared by user | New token generated |
| Play Services updated | Possible token refresh |
| App restored from backup on new device | New token generated |
| User changes device | New token generated |
Your backend must handle stale tokens (HTTP 404 from FCM send API → delete that token from your database).
Message Types
FCM messages fall into two categories with fundamentally different delivery and handling behaviour.
Notification Messages
The FCM server sends a notification payload. When the app is in the background or killed, the FCM SDK itself displays the notification — your code does not run at all. When the app is in the foreground, your onMessageReceived is called and you decide how to display it.
1
2
3
4
5
6
7
8
9
{
"message": {
"token": "device_registration_token",
"notification": {
"title": "New message",
"body": "Alice sent you a message"
}
}
}
Data Messages
The FCM server sends a data payload only. Your onMessageReceived is always called, regardless of whether the app is foreground, background, or killed. You are fully responsible for displaying a notification (if desired) and processing the data.
1
2
3
4
5
6
7
8
9
10
{
"message": {
"token": "device_registration_token",
"data": {
"type": "new_order",
"order_id": "12345",
"amount": "59.99"
}
}
}
Combined Messages
A message can have both notification and data payloads. Background/killed behaviour follows the notification payload (FCM displays it); foreground behaviour calls onMessageReceived with both payloads in the remoteMessage.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"message": {
"token": "device_registration_token",
"notification": {
"title": "Order confirmed",
"body": "Your order #12345 has been confirmed"
},
"data": {
"order_id": "12345",
"deep_link": "myapp://orders/12345"
}
}
}
Summary Table
| App state | Notification message | Data message | Combined |
|---|---|---|---|
| Foreground | onMessageReceived |
onMessageReceived |
onMessageReceived |
| Background | FCM SDK shows notification; tap opens app | onMessageReceived |
FCM SDK shows notification; onMessageReceived for data |
| Killed | FCM SDK shows notification; tap opens app | onMessageReceived |
FCM SDK shows notification; data in Intent extras on tap |
Firebase Docs — Receive messages in an Android app
Handling Messages in FirebaseMessagingService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
// Data payload — always available, process first
val data = remoteMessage.data
val type = data["type"]
val orderId = data["order_id"]
// Notification payload — present only for notification/combined messages
remoteMessage.notification?.let { notification ->
val title = notification.title
val body = notification.body
}
when (type) {
"new_order" -> handleNewOrder(orderId)
"chat_message" -> handleChatMessage(data)
"promo" -> showPromotionalNotification(remoteMessage)
else -> showGenericNotification(remoteMessage)
}
}
private fun handleNewOrder(orderId: String?) {
orderId ?: return
// Option A: process immediately in a coroutine
CoroutineScope(Dispatchers.IO).launch {
orderRepository.syncOrder(orderId)
}
// Option B: enqueue durable work via WorkManager
WorkManager.getInstance(applicationContext)
.enqueue(
OneTimeWorkRequestBuilder<OrderSyncWorker>()
.setInputData(workDataOf("order_id" to orderId))
.build()
)
}
private fun showGenericNotification(remoteMessage: RemoteMessage) {
val notification = remoteMessage.notification ?: return
val notif = NotificationCompat.Builder(this, GENERAL_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(this)
.notify(System.currentTimeMillis().toInt(), notif)
}
override fun onNewToken(token: String) {
sendTokenToServer(token)
}
}
Message Priority — Normal vs High
FCM messages have two priority levels that directly control Doze behaviour:
| Priority | FCM JSON field | Android behaviour |
|---|---|---|
normal (default) |
"priority": "normal" |
Delivered when device exits Doze; batched for efficiency |
high |
"priority": "high" |
Wakes the device from Doze immediately; onMessageReceived fires right away |
High-priority messages are how FCM pierces Doze Mode. They are the correct solution for incoming calls (VoIP), urgent alerts, and any scenario that cannot tolerate a maintenance-window delay.
1
2
3
4
5
6
7
8
9
10
11
12
{
"message": {
"token": "device_token",
"android": {
"priority": "HIGH"
},
"data": {
"type": "incoming_call",
"caller": "Alice"
}
}
}
Abuse warning: Google monitors high-priority usage. Apps that send high-priority messages for non-urgent content (marketing, general chat) will have their priority silently downgraded by Google. Reserve high-priority for content that is genuinely time-critical.
Notification Channel Override
FCM can specify which notification channel to use for background notification messages. Set android.notification.channel_id in the message payload — if the channel does not exist on the device, FCM falls back to the app’s default channel.
1
2
3
4
5
6
7
8
9
10
11
12
{
"message": {
"token": "device_token",
"android": {
"notification": {
"channel_id": "alerts_channel",
"title": "Payment failed",
"body": "Your payment for order #12345 failed"
}
}
}
}
Extracting Data from a Notification Tap (Background)
When the app is killed and a notification-type message is shown by the FCM SDK, tapping the notification opens the launcher Activity. The data payload is delivered via the Intent extras:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Check if launched from an FCM notification tap
intent.extras?.let { extras ->
val orderId = extras.getString("order_id")
val deepLink = extras.getString("deep_link")
if (orderId != null) {
navigateToOrder(orderId)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle if Activity is already running (single-top / singleTask)
intent.extras?.getString("order_id")?.let { navigateToOrder(it) }
}
}
Set a click_action in the notification payload to route the tap to a specific Activity:
1
2
3
4
5
{
"notification": {
"click_action": "OPEN_ORDER_ACTIVITY"
}
}
1
2
3
4
5
6
7
<!-- AndroidManifest.xml — declare the intent filter on the target Activity -->
<activity android:name=".OrderActivity">
<intent-filter>
<action android:name="OPEN_ORDER_ACTIVITY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
Topic Messaging
Subscribe devices to topics to send messages to multiple devices without managing token lists yourself:
1
2
3
4
5
6
7
8
// Subscribe
FirebaseMessaging.getInstance().subscribeToTopic("breaking_news")
.addOnCompleteListener { task ->
if (task.isSuccessful) Log.d(TAG, "Subscribed to breaking_news")
}
// Unsubscribe
FirebaseMessaging.getInstance().unsubscribeFromTopic("breaking_news")
1
2
3
4
5
6
7
8
9
{
"message": {
"topic": "breaking_news",
"notification": {
"title": "Breaking: Major earthquake detected",
"body": "A 7.2 magnitude earthquake has been detected in…"
}
}
}
Token Management — Server Side
Your server must:
- Store the token on first registration and on every
onNewTokenupdate - Handle HTTP 404 responses from the FCM Send API (invalid token) by deleting that token from your database
- Handle HTTP 400
INVALID_ARGUMENT/UNREGISTEREDerrors similarly
1
2
3
4
5
6
7
8
// Example server-side logic (Kotlin/Ktor backend)
suspend fun sendNotification(token: String, payload: NotificationPayload) {
val response = fcmClient.send(token, payload)
if (response.statusCode == 404) {
// Token is invalid — device uninstalled the app or cleared data
tokenRepository.delete(token)
}
}
FCM in the Background Execution Stack
FCM is not a background execution mechanism — it is a wake-up signal. The pattern is:
1
2
3
4
5
6
7
8
9
10
11
Server sends high-priority FCM data message
│
▼
Device wakes from Doze (high-priority bypasses it)
│
▼
onMessageReceived() fires in your FirebaseMessagingService
│
├─ Short work → CoroutineScope(Dispatchers.IO).launch { }
│
└─ Durable work → WorkManager.enqueue(OneTimeWorkRequestBuilder<MyWorker>().build())
onMessageReceived has approximately 10 seconds to complete its work on older versions (before the process may be killed). For anything longer, hand off to WorkManager immediately.
Testing FCM Locally
Firebase Console
Use the Firebase Console → Cloud Messaging → Send Test Message to send a single message to a specific registration token. This is the fastest way to test onMessageReceived and notification appearance.
fcm-push CLI / cURL
1
2
3
4
5
6
7
8
9
10
11
curl -X POST \
https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "DEVICE_REGISTRATION_TOKEN",
"data": { "type": "test", "value": "hello" },
"android": { "priority": "HIGH" }
}
}'
Emulator Support
FCM works on Android emulators that have the Play Store image (Google APIs). Standard AOSP emulators do not have Play Services and will not receive FCM messages.
← Previous: AlarmManager · ↑ Chapter Index · Next: Background Limits History →