How to Implement Push Notifications using Firebase FCM Framework
Push notifications keep an app useful when it is not open. A chat message, a delivery update, a price alert or a reminder can reach the user at the right moment, and the user can act on it with one tap. Firebase Cloud Messaging, usually called FCM, gives Android developers a reliable way to send those messages without running their own delivery network.
I have wired FCM into apps more than once, and the same pattern works every time: a Firebase project, a configuration file, a messaging dependency, a service that receives messages, a channel that displays them, and a token that tells your server which device to reach. None of the steps is hard on its own. The trouble starts when one of them is skipped, because the failure looks like a delivery problem when it is really a setup problem.
This guide follows the full path in order. You will create a Firebase project, register your Android app, add the Gradle dependencies, write a FirebaseMessagingService subclass, create notification channels for Android 8 and higher, handle tokens and topics, tell data messages apart from notification messages, and test everything with the Firebase console and with curl. By the end you will have a working push setup you can test on a real device.
How Firebase Cloud Messaging Delivers Push Notifications on Android
FCM sits between your server and your users’ devices. Your server sends a message to FCM, FCM routes it to the right device, and the Android system wakes your app or shows the notification. You never hold a socket open yourself, and you never track which devices are online. Google’s infrastructure handles the routing, the retries and the offline queue.
Each app install gets a registration token, a long string that identifies that one install on that one device. Your app requests the token from Firebase, sends it to your server, and your server stores it next to the user account. When you want to reach that user, your server sends a message addressed to that token. If the token changes, the app must send the new one promptly, or messages will silently go nowhere.
Notification messages vs data messages in FCM
FCM carries two kinds of payload, and mixing them up is the most common source of confusion. A notification message carries a title and a body, and the system displays it on its own when the app is in the background. A data message carries custom key-value pairs that your code reads and acts on, such as updating a conversation list or syncing a record. Many apps need both: a notification message for a simple alert, and a data message when the app must do work in code. Choose the type when you design the feature, not after you ship it.
A message can carry both parts at once, which is useful for a chat alert that also needs to update the conversation list. In that case the system shows the notification part in the background, and your service receives the data part when the user taps it or when the app is in the foreground. Test both states, because the behaviour changes with the app state.
What happens when the app is in the foreground, background or killed
App state changes everything about delivery. In the foreground, your FirebaseMessagingService receives the message directly, and you decide what to show. In the background, the system shows a notification message on its own, and your service only sees the data payload when the user taps the notification. When the app has been force-stopped, delivery may be delayed or skipped until the user opens the app again. That last case surprises many developers, so test it explicitly rather than assuming it works.
Doze mode and app standby add another layer. A device that has been idle may delay a normal-priority message until the next maintenance window. High-priority messages wake the device sooner, but they should be reserved for time-sensitive alerts, because overuse trains the system to treat your messages as low value. Use normal priority for anything that can wait a few minutes.
The role of the registration token in delivery
The token is the address on the envelope. Without a current token, your server cannot reach the device at all. Tokens change when the app is reinstalled, when app data is cleared, and occasionally when Firebase rotates them. Your service must catch the refresh event and send the new token to your server every time, without waiting for the user to open a particular screen.
How FCM handles offline devices, collapse keys and time to live
When a device is offline, FCM holds the message for a while and delivers it when the device reconnects. A collapse key lets you replace an old undelivered message with a newer one, so a user who was offline for a day gets one fresh score update instead of twenty stale ones. Time to live sets how long FCM keeps trying: a chat message may deserve a day, while a one-time code deserves minutes. Set both fields deliberately for each message type.
Step 1: Create a Firebase Project and Register Your Android App
Everything starts in the Firebase console. You create a project, register your Android app inside it, and download the configuration file that ties the two together. The whole step takes about ten minutes if your package name is already decided.
- Open the Firebase console and sign in with the Google account that owns the project.
- Click Add project, give it a name that matches your app, and accept the default settings. You can skip Google Analytics for now if the app does not need it yet.
- Wait for the project to finish provisioning, then open the project overview.
- Click the Android icon to add an Android app to the project.
- Type your application ID exactly as it appears in your app-level build file, for example com.example.shop. This is the field that must match, character for character.
- Give the app a nickname you will recognise, then click Register app.
- Download google-services.json and move it into the app module folder of your project, next to the app-level build file.
- Continue through the remaining console steps and finish. You will add the SDK in the next step of this guide.
Matching the package name exactly before you register
The package name is the link between your app and your Firebase project, and a mismatch is the most common setup failure in FCM work. Open your app-level build file, find the applicationId, and copy it rather than typing it from memory. A single wrong character means the token request will fail later with an error that says nothing about the package name. Check it now, while the fix is a thirty-second edit.
Build variants complicate this. A debug build and a release build can carry different application IDs if you use suffixes, and each one needs its own registration in the same Firebase project. Register both, and download a fresh google-services.json after adding the second one, because the file holds an entry for every registered app.
What google-services.json actually contains
The google-services.json file holds your project number, your project ID, the API key for your app, and the entries for each registered Android app. It is not a secret in the same sense as a server key: it ships inside your app, and anyone can read it from the APK. Even so, do not publish it in a public repository or a screenshot, and keep your server-side credentials, such as the service account key, out of the app entirely. The file belongs in the app module folder, and the Google services plugin reads it at build time.
One Firebase project per build flavour
Most teams use one Firebase project for production and a second project for development, so test messages never reach real users. If you use product flavours instead, register each flavour as a separate Android app inside the right project. The rule is simple: a device running a development build should never hold a token that the production server can message.
Step 2: Add google-services.json and Gradle Dependencies for FCM
With the project registered, the next step is wiring Firebase into the build. You add the Google services plugin, the messaging dependency, and then sync. A clean build at this stage makes every later step easier, because a build error here looks identical to a messaging error later.
Add the plugin to your project-level build file or your version catalog, then add the messaging library to the app-level build file. The Firebase BoM, the Bill of Materials, keeps every Firebase library on a compatible version, which removes a whole class of version-conflict crashes.
// Project-level build file, plugins block
plugins {
id("com.android.application") version "8.5.0" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
// App-level build file, plugins block
plugins {
id("com.android.application")
id("com.google.gms.google-services")
}
// App-level build file, dependencies block
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.2.0"))
implementation("com.google.firebase:firebase-messaging")
}
After editing, sync the project with Gradle files and confirm the build succeeds before writing any messaging code. If the sync fails, the error is usually a missing google-services.json file, a plugin version that does not match your Gradle version, or a dependency conflict with another library. Fix the build now, while the change is small and the cause is obvious. A note on dependencies in general: keep an eye on third-party Android library vulnerabilities when you add any library, and update libraries that carry known issues rather than pinning an old version forever.
Using the Firebase BoM to keep versions aligned
The BoM is a single version that pins every Firebase library to a tested combination. You declare the BoM once with platform, and then you list firebase-messaging without a version number. When you update the BoM, every Firebase library moves together. Without it, you can easily end up with messaging on one version and core Firebase on another, and the resulting crash will point at a class that looks unrelated to your change.
Syncing Gradle and fixing common build errors
Three errors cover most Gradle failures at this step. A missing google-services.json produces a clear error naming the file: put it in the app module folder, not the project root. A plugin version mismatch produces an error about the Android Gradle plugin: raise or lower the Google services plugin to a version your Gradle supports. A duplicate class error usually means two Firebase libraries at different versions: remove the explicit version from the messaging dependency and let the BoM decide.
Groovy DSL vs Kotlin DSL dependency syntax
The code above uses Kotlin DSL, which is the default for new projects. If your project uses Groovy, the same lines look slightly different: single quotes instead of double quotes, and no parentheses around the plugin version. The meaning is identical, and the google-services.json file works the same way in both. Pick the syntax that matches your existing build files and keep it consistent across the project.
Step 3: Build Your FirebaseMessagingService Subclass
The service is the heart of your push implementation. It is a small class that extends FirebaseMessagingService, receives messages, and reacts to token changes. The system starts it when a message arrives, even if your app is not running, which is why it must be registered in the manifest with the right intent filter.
Write the class first, then register it, then test it with a log line before building any notification display code. That small test confirms the plumbing works, and it separates a registration problem from a display problem later.
class PushService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
Log.d("PushService", "Message from: " + message.from)
val title = message.notification?.title ?: "New update"
val body = message.notification?.body ?: ""
val orderId = message.data["order_id"] ?: ""
showNotification(title, body, orderId)
}
override fun onNewToken(token: String) {
Log.d("PushService", "New token received")
sendTokenToServer(token)
}
private fun showNotification(title: String, body: String, orderId: String) {
// Build and show the notification here, using a channel.
// See Step 4 for the channel setup.
}
private fun sendTokenToServer(token: String) {
// POST the token to your app server, next to the user account.
// Retry on failure; a lost token means lost messages.
}
}
Register the service in AndroidManifest.xml with the messaging event action. Without this entry, messages may arrive at the device but your code will never see them, and the failure will look like a server problem when it is really a manifest problem.
<service
android:name=".PushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
Receiving messages in onMessageReceived
The onMessageReceived callback runs when a message arrives while the app is in the foreground, and for data messages in the background as well. Read the notification part for the title and body, and read the data map for your custom keys, such as an order ID or a chat ID. Keep this method fast: show a notification, update a local record, or schedule background work, and return. Heavy work belongs in a worker, not in the callback, and blocking here delays every later message.
Sending the new token to your app server
The onNewToken callback fires when Firebase issues a fresh token. Your job is to send it to your server and store it next to the user account, replacing the old one. Do this on a background thread, retry on network failure, and never log the full token. A token in a log file is a small leak that becomes a real problem if logs are ever shared. Also fetch the token once at startup with FirebaseMessaging.getInstance().token, so a fresh install registers even if the refresh event fired before your server was ready. This is one of those places where handling lifecycle state changes without crashes matters: request the token from a stable point in the app lifecycle, not from a short-lived screen that may be gone before the callback returns.
Logging delivery without leaking tokens
Log that a message arrived, its type, and its message ID, but never the token and never the full payload if it carries personal data. A log line such as “message received, id 0:1234, type data” is enough to debug delivery, and it stays safe to paste into a bug report. Keep a short local log of recent message IDs during development, so you can tell a duplicate delivery from a new message.
Step 4: Create Notification Channels for Android 8 and Higher
Android 8, API level 26, introduced notification channels, and every notification your app shows must belong to one. A channel groups notifications of one type, such as order updates or chat messages, and the user controls the sound, vibration and importance of each channel from system settings. Create your channels once, when the app starts, before you show the first notification.
A notification channel has an ID, a name the user sees, a description, and an importance level. The ID is permanent: once the channel exists, the user owns its settings, and your code cannot change the importance later. That is why the ID and the importance deserve thought up front, not a quick guess.
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val orders = NotificationChannel(
"orders",
"Order updates",
NotificationManager.IMPORTANCE_DEFAULT
)
orders.description = "Delivery status and order changes"
val chat = NotificationChannel(
"chat",
"Chat messages",
NotificationManager.IMPORTANCE_HIGH
)
chat.description = "New messages from your conversations"
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(orders)
manager.createNotificationChannel(chat)
}
}
Call this function from your Application class onCreate, so the channels exist before any message arrives. Then, when you build a notification, set its channel ID to “orders” or “chat” as appropriate. A notification built without a channel simply does not appear on Android 8 and higher, and the failure is silent, which makes this step easy to miss and hard to debug.
Choosing channel importance and sound
Importance decides how loudly a notification arrives. IMPORTANCE_HIGH pops on screen with sound, IMPORTANCE_DEFAULT makes a sound without popping, IMPORTANCE_LOW shows quietly in the shade, and IMPORTANCE_MIN hides almost entirely. Chat messages usually deserve high importance, order updates suit the default level, and a weekly summary belongs on low. Pick the lowest level that still serves the user, because a loud channel that cries wolf gets muted or disabled entirely.
Asking for notification permission on Android 13
Android 13, API level 33, made notifications opt-in. Your app must request the POST_NOTIFICATIONS runtime permission before it can show anything, and the system only lets you ask once in a natural way, so the timing matters. Ask when the user does something that makes notifications useful, such as placing an order or joining a chat, and explain in one line what they will get. Asking on first launch, before the user understands the value, is the fastest way to a permanent denial.
if (Build.VERSION.SDK_INT >= 33) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
Updating a channel after it already exists
You cannot change the importance of an existing channel from code, and you cannot change its sound once the user has touched it. You can change its name and description, and you can delete a channel and create a new one with a different ID, but deleting also resets the user’s preferences for it. The practical rule is to get the channel design right before release, and to treat a new channel ID as a new feature, not as a fix for a typo.
Step 5: Handle FCM Tokens, Topics and Data Messages
Tokens and topics are the two ways to address a message. A token reaches one device, and a topic reaches every device subscribed to it. Most apps use both: topics for broadcasts such as news or promotions, and tokens for personal messages such as an order update or a chat reply.
Fetch the token at startup, keep it fresh through the refresh callback, and store it on your server next to the user account. Subscribe to topics when the user’s interests change, and unsubscribe when they change again. Both operations are asynchronous, so handle the result rather than assuming success.
// Fetch the current token once, at startup
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
sendTokenToServer(token)
}
}
// Subscribe this device to a topic
FirebaseMessaging.getInstance().subscribeToTopic("news")
.addOnCompleteListener { task ->
Log.d("PushService", "Topic subscription done: " + task.isSuccessful)
}
Subscribing users to topics in FCM
Topics are simple and powerful. A news app subscribes every user to a general news topic, a sports app subscribes by team, and a shop subscribes by category. The server sends one message to the topic, and FCM fans it out to every subscribed device. Topics also keep your server simple, because it never needs a list of tokens for a broadcast. The trade-off is control: you cannot target one user through a topic, and a user who unsubscribes on one device may stay subscribed on another, so keep topic state in sync with your server record.
Data messages and silent updates
A data message carries no visible notification, which makes it the right tool for silent work: refreshing a list, marking a message as read, or updating a badge count. Your service receives it, does the work, and decides whether to show anything. Keep the payload small and the work short, and hand anything longer to a worker. A useful habit is to treat data messages as a nudge, not as a delivery truck: send the ID of the thing that changed, and let the app fetch the fresh data itself. When that background work has limits to respect, the guide on managing WorkManager tasks under background limits covers how to schedule it so the system does not kill it halfway through.
When to refresh a token and what to do with the old one
Refresh the token whenever onNewToken fires, and also fetch it again after the user signs in or switches accounts, because the token belongs to the install, not the account, and the server mapping needs to follow the account. When a new token arrives, replace the old one on the server rather than adding a second row. A server that keeps stale tokens will send messages to dead addresses and report success for deliveries that never happened.
Step 6: Test Push Notifications With the Firebase Console and curl
Testing is where most FCM setups finally prove themselves, and it deserves the same care as the code. Test on a real device, not only on an emulator, because an emulator with Play services can behave differently from a phone in a pocket. Test in the foreground, in the background, and after force-stopping the app, so you know what each state does.
Start with the Firebase console, because it needs no server code at all. Once the console test arrives, move to curl against the HTTP v1 API, because that is how your server will send messages in production.
- Install the app on a real device and open it once, so the token registers and the permission prompt is answered.
- Open the Firebase console, go to Cloud Messaging, and click Send your first message.
- Fill in a notification title and body, then click Send test message.
- Paste the device token from your startup log, add the device, and send.
- Watch the device. The notification should arrive within seconds on a good connection.
- Repeat the test with the app in the background, then in the foreground, and note the difference in behaviour.
- Send a data message from your server or from curl, and confirm your service receives the payload.
Sending a test message from the Firebase console
The console composer is the fastest way to check the whole chain. It asks for a title, a body, and a target, and it handles the server side for you. Use a test token rather than a topic for the first run, so a mistake reaches one device instead of every subscriber. If the message never arrives, check the token first, then the permission, then the channel, in that order. Nine times out of ten, the fault is one of those three, not the FCM service itself.
Sending a test message with curl and the HTTP v1 API
The HTTP v1 API is the current way for a server to send FCM messages. It uses a service account and an OAuth 2.0 access token instead of the old server key, which is safer and is the only method new projects should use. The curl example below sends a notification message to a single token. Replace the project ID, the access token and the device token with your own values before running it.
curl -X POST "https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send"
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
-H "Content-Type: application/json"
-d '{
"message": {
"token": "DEVICE_TOKEN",
"notification": {
"title": "Order shipped",
"body": "Your order is on its way."
},
"data": {
"order_id": "48210"
}
}
}'
Get the access token from your service account with the Google Auth library or the gcloud command line, and keep that key on your server, never in the app. A short-lived access token is safer than a long-lived server key, and it can be revoked without changing the app.
Reading the FCM response and message IDs
A successful send returns a name field that holds the message ID, such as projects/your-project/messages/0:123456789. Save that ID in your server log next to the token you targeted. If the send fails, the response carries an error code: NOT_FOUND means the token is stale and should be removed, INVALID_ARGUMENT means the payload is malformed, and UNAUTHENTICATED means the access token is wrong or expired. Each code points at a different fix, so read it before retrying.
FCM Troubleshooting, Background Limits and FAQ
When push breaks, the symptom rarely names the cause. A message that never arrives can mean a stale token, a missing permission, a wrong channel, a payload the app ignores, or a server that never sent it. Work through the checks below in order, and change one thing at a time, so you know which change fixed it.
Why did my test message never arrive?
Check the token first: confirm the app fetched a current token and your server stored it. Then check the permission: on Android 13 and higher, a denied notification permission means nothing appears, even though FCM delivered it. Then check the channel: a notification without a valid channel ID is dropped silently on Android 8 and higher. Then check the payload: a data-only message produces no visible alert by design, so it may have arrived and been processed without showing anything. Finally, check the server log for the FCM response and its error code.
Token refresh and NotRegistered errors
A NotRegistered error means the token is dead: the app was uninstalled, the data was cleared, or the token was replaced. Remove that token from your server the moment you see this error, because retrying it only wastes quota and pollutes your logs. A token that refreshes often points to a test device that is reinstalled a lot, which is normal in development and rare in production. In production, a steady rate of token refreshes is expected and your server should handle it as routine maintenance.
Messages arrive late on some devices
Late delivery usually comes from battery optimisation, not from FCM. Doze mode delays normal-priority messages on idle devices, and some manufacturers add their own aggressive battery savers on top. High priority wakes the device sooner, but reserve it for messages the user needs now, such as a delivery at the door. For the rest, normal priority and a little patience are the right call. Asking users to disable battery optimisation for your app is a last resort, and it should come with a clear explanation of why it helps.
Do I need a server to use FCM?
You need something that sends messages, but it does not have to be a server you own at first. The Firebase console sends test messages with no server code, and a small cloud function can send messages in response to database changes. In production, most apps use their own server, because it already knows which users to message and when. The console is for testing, not for running a live product.
Can FCM deliver messages when the app is killed?
Usually yes, for notification messages, because the system itself displays them. Data messages are less certain when the app has been force-stopped, because the system will not restart a force-stopped app to deliver them. A user who swipes the app away from recents is different from a user who force-stops it in settings: the first still receives messages, the second may not until the app opens again. Test both cases on a real device so your expectations match reality.
How do I send a push notification to one user instead of everyone?
Send to that user’s token, or to a topic that only that user subscribes to, such as a per-user topic named after the account ID. Tokens are the direct route and the usual choice for personal messages. Topics suit groups and broadcasts. Never send a personal message to a broad topic, because every subscriber will see it, and that mistake is hard to undo once the message is out.
Is Firebase Cloud Messaging free to use?
Yes, FCM itself has no per-message charge, and there is no limit that a normal app will hit. The costs around it are the server that sends the messages and any other Firebase products you use, such as the database or cloud functions. That makes FCM one of the cheapest parts of a push setup: the work is in the implementation, not in the bill.
Put the pieces together and the full flow is short: a project, a configuration file, a dependency, a service, a channel, a token, and a test. Each step is small, and each one can be checked on its own before you move to the next. The fastest way to finish today is to get the console test message arriving on your own phone, then replace the console with your server one step at a time. Once that first message lands, the rest of the implementation is refinement, not guesswork.
Start with the project and the token, confirm the console test, and then build outward from there. Your users will feel the difference the first time a timely message arrives exactly when it should.


