Implementing Push Notifications in Android Apps via Firebase Cloud Messaging
A message arrives while the phone sits face down on a desk, the screen lights up, and a user who had forgotten your app opens it again within seconds. That quiet moment is the whole point of push notifications. A well timed alert about a reply, a delivery, a price change, or a live score can bring someone back at exactly the right time. A badly timed alert about nothing important can make the same person open Settings, turn notifications off, and never look back. I have watched both outcomes in apps I shipped, and the difference was rarely the message text. It was the plumbing underneath: tokens that were never refreshed, channels that lumped urgent alerts with marketing noise, and deep links that landed on a home screen instead of the content the message promised.
Firebase Cloud Messaging, usually called FCM, is the free Google service that carries those messages from a server to Android devices. Your backend decides what to send and to whom, FCM routes it through a persistent connection that Android already maintains, and your app receives it in a service class that decides what to show. You do not run your own push servers, you do not keep a socket open all day, and you do not poll a server every few minutes and drain the battery. That is the trade you are making: Google handles routing, delivery, and the low level connection, while you handle tokens, permission, channels, presentation, and respect for the person holding the phone.
This guide walks through the full implementation in order, from creating a Firebase project and adding the configuration file, through requesting a token, writing a FirebaseMessagingService subclass in Kotlin, creating notification channels for Android 8 and later, asking for the Android 13 notification permission at the right moment, handling notification messages and data messages in the foreground and in the background, adding topics, deep links, and grouped summaries, and finally measuring open rates and fixing the delivery failures that show up on real devices from Samsung, Xiaomi, and other makers with aggressive battery rules. Every step names the real menu, file, class, or method involved, so you can follow it in Android Studio without guessing.
How Firebase Cloud Messaging delivers a push notification in Android
A push system has three parts, and each one can fail on its own. First, your application server decides that something happened: a chat reply arrived, an order shipped, a friend liked a photo, or a live event started. Second, FCM receives a request from that server, looks up the target, and routes the message over a persistent connection to the device. Third, your app receives the message and either shows a notification or processes the data quietly. When a message never arrives, the fault sits in exactly one of those three parts, and the fastest debugging habit is to check them in order: did the server send it, did FCM accept it, and did the app handle it.
The target of a message is a registration token, a long string that identifies one app installation on one device. FCM creates it when your app asks, and it can change after an app reinstall, after app data is cleared, after a device restore, or when Firebase rotates it for security. Your server must treat the token as a value that changes, not as a permanent user identifier. Keep it next to the user record, update it whenever the app reports a new one, and delete it when FCM tells you it is no longer valid. Sending to a stale token fails silently from the user point of view, which is why token handling deserves the same care as the message content itself.
Notification messages versus data messages in FCM
FCM supports two payload types, and mixing them up is the most common source of confusion for new developers. A notification message carries a title and a body, plus an optional image, and the Android system can display it on its own when your app is in the background, without running your code at all. A data message carries only custom key value pairs, such as order_id or chat_id, and your FirebaseMessagingService code always receives it, in the foreground and in the background, so you can decide what to do: show a custom notification, save the data, refresh a cache, or ignore it. A combined message carries both parts, and its behaviour depends on app state, which is why you must test every message type in both states before you ship.
When to use a notification message
Use a notification message for a simple alert that needs no custom logic: a title, a body, and a tap that opens the app. The system renders it, handles the sound and vibration from the channel, and shows it even if your process is dead. The trade off is control. You cannot run code before it appears, you cannot change its layout beyond the standard fields, and you cannot suppress it based on the current screen. For a chat app, a news alert, or a delivery update, that simplicity is usually the right choice.
When to use a data message
Use a data message when the app must decide what happens. A chat app can check whether the conversation is already open and skip the notification, a sports app can update a score silently and only alert on a goal, and a delivery app can refresh tracking data and then show a notification with the latest status. Data messages give you that decision point, and they are the right tool for silent sync, for custom presentation, and for messages that should never appear as a raw alert. The cost is responsibility: if your code crashes or takes too long, the message is lost, so keep the handler short and move heavy work to WorkManager.
The table below summarises the choice so you can pick the right payload before you write the server code, rather than discovering the difference in a bug report.
| Payload type | What it carries | Shown automatically in background | Best use |
|---|---|---|---|
| Notification message | Title, body, optional image | Yes, by the system | Simple alerts with no custom logic |
| Data message | Custom key value pairs only | No, your code decides | Silent sync, custom alerts, conditional display |
| Combined message | Both notification and data parts | Yes in background, code in foreground | Alerts that also need identifiers for a deep link |
Setting up a Firebase project and adding FCM to Android Studio
Start in the Firebase console at console.firebase.google.com. Click Add project, give it a name that matches your app, and accept the default Google Analytics setting unless your team has a reason to change it. Once the project exists, click Add app, choose the Android icon, and enter your application identifier exactly as it appears in your module level build.gradle.kts file, in the applicationId field. A single character difference here means the configuration file will not match your app, token requests will fail, and you will spend an afternoon chasing a typo. Enter a nickname for the app, leave the debug signing certificate empty for now unless you need it for another Firebase product, and click Register app.
Adding google-services.json to your Android project
Download the google-services.json file from the console and place it in the app module directory of your project, next to the module level build.gradle.kts file, not in the project root and not inside src. That file contains your project number, your API key, and the mapping from your application identifier to the Firebase project. It is safe to commit for most apps because it does not contain a private server key, though teams that prefer caution keep it out of public repositories and inject it during the build. After placing the file, open the project level build.gradle.kts file and add the Google services plugin, then open the module level file and apply the plugin and add the messaging dependency, com.google.firebase:firebase-messaging-ktx, using a current version from the Firebase release notes. Sync the project with Gradle files and confirm the build succeeds before you write any messaging code.
Gradle setup checklist for Firebase Messaging
Check three things after the sync. First, the plugin line appears in both Gradle files and the sync reports no error. Second, the firebase-messaging-ktx dependency resolves and the FirebaseMessaging class is available in code completion. Third, the google-services.json file sits in the app directory and the build log shows no warning about a missing configuration file. If the build complains that the file is missing, check the path first, because Android Studio will not search subdirectories for it. I keep a screenshot of the working Gradle files in the team wiki, because a new developer joining the project always asks where the file goes.
Open AndroidManifest.xml and confirm that the Firebase messaging service is declared, or add it yourself if you prefer an explicit entry. The service needs an intent filter for com.google.firebase.MESSAGING_EVENT and it should be exported as false, because only Firebase should start it. You will create the service class itself in the next section, and the manifest entry points FCM at that class. Also confirm that your app has no second messaging receiver fighting for the same intent, a leftover from an older push provider, because two receivers can swallow each other messages in ways that look random in testing.
Writing a FirebaseMessagingService subclass in Kotlin
Create a new Kotlin class called MessagingService that extends FirebaseMessagingService. This class is the heart of your push implementation, and it has two methods you must override. The first is onMessageReceived, which FCM calls with a RemoteMessage whenever a data message arrives, or a notification message arrives while the app is in the foreground. The second is onNewToken, which FCM calls whenever a new registration token is created, including the first launch after install. Both methods run on a background thread, so keep them short, avoid network calls that block, and hand heavy work to a coroutine scope or to WorkManager.
Inside onMessageReceived, read the data map first. A typical chat message carries keys such as type, chat_id, sender_name, and message_id. Log the keys during development, never in production, and decide what to show. If the data says the message is a silent sync, save it and return without showing anything. If it is an alert, build a notification with NotificationCompat.Builder, set the channel identifier, the small icon, the title, and the body, attach a PendingIntent that opens the right screen, and post it through NotificationManagerCompat. A small icon is mandatory: a notification without one may not appear at all, and a white icon on a transparent background is the classic mistake that makes an alert look blank in the shade.
A complete Kotlin FirebaseMessagingService example
The code below is a compact, working starting point. It handles a data message, reads the title from the notification part when one is present and falls back to a default, forwards a new token to the server, and shows a notification on a channel called chat_messages. Read it line by line before you paste it, and adapt the channel identifier and the deep link target to your own app. The sendTokenToServer function is a placeholder for your real API call, which should run in a coroutine and retry once on a transient network error.
class MessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
val title = message.notification?.title ?: data["title"] ?: "Update"
val body = message.notification?.body ?: data["body"] ?: ""
if (data["type"] == "silent_sync") return
showNotification(title, body, data["chat_id"])
}
override fun onNewToken(token: String) {
sendTokenToServer(token)
}
private fun showNotification(title: String, body: String, chatId: String?) {
val channelId = "chat_messages"
val intent = Intent(this, ChatActivity::class.java).apply {
putExtra("chat_id", chatId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pending = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(pending)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(this).notify(chatId.hashCode(), notification)
}
}
Notice the PendingIntent flags. FLAG_IMMUTABLE is required on Android 12 and later, and forgetting it produces a crash that only appears on newer devices, which is exactly the kind of bug that slips through testing on an old phone and then hits users. The notification identifier uses the chat identifier hash so that a new message in the same chat replaces the old one instead of stacking ten alerts for one conversation. For a summary style, keep a counter per chat and use NotificationCompat.InboxStyle or MessagingStyle to show the last few lines in one notification, which reads far better than a pile of single line alerts.
Requesting the FCM token and sending it to your server
A token is requested with FirebaseMessaging.getInstance().token, which returns a Task that completes with the current token string. Call it after the user signs in, or on first launch if your app works without an account, and do it from a coroutine or a callback, never on the main thread in a blocking way. The call can fail when the device has no network, when Google Play services is missing or outdated, or when the Firebase configuration is wrong, so log the exception during development and show nothing to the user: a token failure is a background problem, not a screen the user should see.
When the token arrives, send it to your server over HTTPS with the user identifier and the device model, and store it in a table that maps one user to many tokens, because one person can own a phone and a tablet. Include a timestamp so the server can tell how old a token is, and include the app version so you can stop sending to versions that no longer handle a message type. Your server should accept the same token more than once without creating duplicate rows, because onNewToken can fire again for a token you already hold, and a duplicate row turns one message into two identical alerts on the same phone.
Handling token refresh and stale tokens on the server
Override onNewToken in your service and treat every call as a command to update the server record immediately. Do not wait for the next app launch, because the user may not open the app for days and the old token may already be dead. On the server side, handle the FCM response codes honestly. A NotRegistered error means the token is gone: delete it from the database and stop sending to it. An InvalidRegistration error means the token was malformed: log it and investigate the client code that stored it. A success with a canonical token in the response means FCM replaced the token: update the stored value. I have seen teams ignore these responses for months, and their send logs filled with dead tokens while real users quietly missed alerts.
Test token refresh the way a user actually triggers it. Clear app data in Settings, then Apps, then your app, then Storage, then Clear data, and relaunch the app: a new token should appear and the server record should update. Uninstall and reinstall the app and check the same thing. Restore the app on a second device and confirm the old device token does not receive messages meant for the new one. These three checks take ten minutes and they catch the token bugs that otherwise surface as a support ticket saying notifications stopped working after I got a new phone.
Creating notification channels and asking for permission the right way
Since Android 8, every notification belongs to a channel, and the channel decides its sound, vibration, lights, and how much it interrupts the user. Create one channel per meaningful category, not one channel per message and not one channel for everything. A chat app needs channels for direct messages, group mentions, and marketing, with distinct names and descriptions that make sense in Settings, then Notifications, then your app name. Give direct messages high importance so they make a sound, give group mentions default importance, and give marketing low importance so it never wakes anyone. The names you choose appear verbatim in system settings, so write them for a person, not for a developer.
Create the channels once, in your Application class or in your main activity, guarded by a check for Android O and later. The code calls NotificationManager.createNotificationChannel with a NotificationChannel object built from an identifier, a user visible name, and an importance level, plus a description that explains what the channel is for. Once a channel exists, the user owns its settings: your app cannot raise its importance, cannot turn its sound back on, and cannot delete it and recreate it to reset the user choice. That limit is deliberate, and working around it by creating a new channel identifier for the same content is the fastest way to lose trust, because users notice when an app they silenced starts shouting again under a new name.
Requesting POST_NOTIFICATIONS permission on Android 13 and later
Android 13 made notification permission a runtime permission, which means a fresh install cannot show any notification until the user grants it. Do not ask on first launch, when the user has no reason to trust you yet. Ask in context, at the moment the user turns on a feature that benefits from alerts: when they join a chat, enable order tracking, or follow a team. Show a short in app explanation first, in plain language, such as Turn on alerts so you know when your order ships, and only then call requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS). If the user declines, keep the app fully usable, keep a quiet path in Settings to enable alerts later, and never ask again on the next launch. For the wider permission pattern behind this flow, read managing permissions dynamically in code safely, which covers the same request, rationale, and permanent denial handling for every runtime permission.
What to do when notification permission is denied
A denial is not a failure, it is a preference, and your app should respect it without sulking. Keep the feature working in the app itself: the chat list still updates when opened, the order screen still shows tracking, and the score screen still refreshes on pull. Add a single row in Settings inside your app, labelled Notifications, that opens the system notification settings for your app with an intent to Settings.ACTION_APP_NOTIFICATION_SETTINGS. When the user returns from that screen, check the permission again and update the row. I have measured opt in rates before and after moving the request into context, and the in context request consistently wins, because the user understands the value at the exact moment they are asked.
Handling foreground and background delivery, deep links, and grouping
Test every message in both states, because the behaviour changes. When the app is in the foreground, onMessageReceived runs for both notification and data messages, and you control everything: you can show a custom notification, show an in app banner, or update the screen silently. When the app is in the background, a notification message is displayed by the system without running your code, and the data part of a combined message is delivered to the launcher activity as extras on the intent when the user taps. That split is the reason a deep link works in testing, when you tap while the app is open, and then fails in the real world, when the app was closed and the tap lands on the wrong screen.
Build the deep link from the data map, not from the notification body. Pass a chat identifier, an order identifier, or a content identifier, and let the target screen load fresh data from the server when it opens, so the notification stays accurate even if the content changed after the message was sent. If the content no longer exists, show a clear empty state that says the item is no longer available, instead of crashing or showing a blank screen. A notification tap should feel like a shortcut to the exact thing mentioned, not a detour through the home screen that forces the user to search for it.
Grouping related notifications into one summary
Five alerts in five minutes feel like spam, even when each one is useful on its own. Group related messages with setGroup and post a summary notification with setGroupSummary, so the shade shows one entry that expands to the individual messages. A chat app groups by conversation, a news app groups by topic, and a shopping app groups order updates under one order. Set a group alert behaviour that sounds once for the group, not once per message, and keep the summary text honest: 3 new messages in Family chat tells the user more than three identical single line alerts. Grouping is a small amount of code and a large improvement in how your app feels.
For the background work that follows a data message, such as saving the payload, refreshing a cache, or scheduling a follow up, use the patterns in coroutines and Flow for background tasks. A coroutine tied to the service lifetime is fine for a quick save, while WorkManager is the right choice for work that must survive a process death, such as uploading an acknowledgement or downloading an image for a rich notification. Never run a long network call inside onMessageReceived itself: FCM gives the service a limited window, and a slow handler can be killed mid work.
Topics, targeting, and sending messages from your server
Topics let users subscribe to a shared interest without your server managing individual tokens for that audience. A sports app offers topics such as team_liverpool or league_premier, a news app offers city_jakarta or topic_technology, and the server sends one message to the topic while FCM delivers it to every subscribed installation. Subscribe with FirebaseMessaging.getInstance().subscribeToTopic, unsubscribe with the matching call, and always reflect the choice in a visible toggle in the app, so the user can see what they follow and leave a topic in one tap. Topics are public identifiers, so never put a user identifier or a secret in a topic name, and never use a topic for a private message that only one person should receive.
On the server, send through the FCM HTTP v1 API, not the legacy API, which Google has retired for new projects. Authenticate with a service account and a short lived OAuth token, build a JSON body with a message object that names a token, a topic, or a condition, and include the Android configuration for priority, time to live, and collapse key. Set time to live honestly: a live score alert is useless after an hour, while a delivery notice stays useful for a day, and a time to live of zero means deliver now or drop it. Use a collapse key for replaceable messages, such as a score update, so a device that was offline receives only the latest score instead of twenty stale ones when it reconnects.
High priority, normal priority, and battery rules on real devices
High priority messages can wake a sleeping device and are the right choice for a chat reply or a security alert that genuinely needs immediate attention. Normal priority messages wait for the device to wake naturally and are the right choice for a news digest or a promotional note. Overusing high priority for routine content drains battery, and Android may throttle your app if it detects the pattern, which makes future urgent messages slower too. Some makers add their own battery rules on top of Android: Xiaomi, Samsung, Oppo, and Vivo devices can delay or drop FCM messages for apps the system considers rarely used, unless the user adds the app to an allow list or disables battery optimisation for it in Settings. Test delivery on a real device from at least one of those makers, with the app closed and the phone idle for an hour, before you promise a delivery time to anyone.
Testing, measuring, and fixing delivery failures
Test with the Firebase console first, then with your own server. In the console, open Cloud Messaging, compose a notification, target a single token pasted from logcat, and send it while the app is open, then again while it is closed, and confirm both paths. Then test a data message from your server with a real payload and confirm the deep link, the channel, and the grouping. Keep a test device that runs the current Android version and a second device that runs an older version, because channel behaviour, permission behaviour, and PendingIntent flags all differ across versions, and a bug that only appears on one version is still a bug for the users on that version.
Measure what matters: delivery rate, open rate, and opt out rate per channel and per message type. A high open rate with a low opt out rate means the messages are relevant and well timed. A rising opt out rate after a new message type launches is a warning to pause that type and adjust its frequency or its copy before users disable everything. Test send times, because an alert at 3 in the morning will be silenced even if the content is good, and respect quiet hours with a server side rule that holds non urgent messages until the morning. A weekly review of these numbers, ten minutes with a dashboard, prevents the slow drift from helpful alerts to noise.
When a message does not arrive, work through the checklist in order. Check the server log for the FCM response and the error code. Check that the token in the database matches the token on the device by logging it in a debug build. Check that the channel exists and that its importance is not set to silent by an earlier test. Check that notification permission is granted on Android 13 and later. Check battery optimisation for the app in Settings, then Battery, then the app entry. Check that the device has network and that a VPN or a work profile is not blocking the FCM connection. Most delivery failures I have investigated ended at one of those six checks, and the fix was a configuration change, not a code rewrite.
For the verification side of this work, apply the same discipline as any other feature: write a test that calls your notification builder with a sample payload and asserts the channel, the title, and the deep link extras, and write an instrumented test that opens the deep link target and confirms the right screen appears. The patterns in testing Android apps with unit and Espresso tests fit notification handling well, because the builder and the intent are pure logic that can be tested without a real FCM send. A test that catches a broken deep link before release is worth far more than a manual tap test on one device.
Frequently asked questions about Firebase Cloud Messaging in Android
How do I get the FCM registration token in Kotlin?
Call FirebaseMessaging.getInstance().token and handle the Task result in a callback or a coroutine. The token arrives as a string; send it to your server over HTTPS, store it next to the user record, and update it whenever onNewToken fires. During development, log the token in logcat with a debug build only, and never print it in production logs or paste it into a public issue tracker, because anyone with the token and your server key could send messages that appear to come from your app.
Why do my push notifications arrive when the app is open but not when it is closed?
The usual cause is a data message with no notification part, handled only by code that never runs because the process was killed, or a notification message whose channel was created with silent importance, or battery optimisation delaying delivery on a device from a maker with aggressive power rules. Check the payload type first, then the channel importance, then the battery settings for the app. Also confirm that the service is declared in the manifest with the messaging intent filter, because without it FCM has nowhere to deliver the message when the app is closed.
Can I send a push notification without a server?
You can send a test message from the Firebase console to a single token or a topic, and that is enough for development and for a small announcement. For production, you need a server or a cloud function that decides who receives what and when, because the console cannot react to a chat reply, an order event, or a user action in real time. A lightweight option is a Firebase Cloud Function triggered by a database write, which keeps the logic close to your data and avoids running a dedicated server for a small app.
What happens to FCM tokens when a user reinstalls the app?
A reinstall creates a new token, and the old token stops working. Your app requests the new token on first launch, onNewToken fires, and the server record should be updated at that moment. If the server keeps sending to the old token, FCM returns a NotRegistered error, and your server should delete that token. Test this flow by uninstalling and reinstalling your own app and watching the server log, because it is the most common reason users report that notifications stopped after they changed phones.
A practical checklist before you ship push notifications
Ship push notifications the way you would ship any feature that touches trust: with a checklist and a small pilot. Confirm the token flow works after install, after clearing data, and after reinstall. Confirm each channel appears in system settings with a clear name and the right importance. Confirm the permission request appears in context and that declining it leaves the app usable. Confirm every message type in the foreground and in the background, confirm the deep link lands on the right screen, and confirm grouped messages collapse into one summary. Confirm the server deletes stale tokens, honours quiet hours, and never sends a high priority message for routine content. Then pilot with a small group, watch the open rate and the opt out rate for a week, and only then widen the audience. A notification that arrives at the right time, on the right channel, with a tap that lands exactly where it promised, feels less like marketing and more like the app doing its job. That feeling is the whole implementation, and it is worth building carefully.
Your next step today is a small one: add the messaging dependency, place the configuration file, create the service class from the example above, and send yourself a test message from the Firebase console. When that test alert appears on your own phone, with the right channel and the right tap target, you have a working foundation. Build the token sync, the channels, and the measurement on top of it, one piece at a time, and keep the user in control at every step. Firebase Cloud Messaging token handling and notification channel importance settings are the two details that decide whether push feels reliable or random, so get those right first and the rest follows.


