Push notifications with Firebase: lessons from production
We've all seen apps that spam notifications relentlessly, crash when handling them, or mysteriously stop delivering messages after a few weeks. Push notifications with Firebase: lessons from production teaches you how to implement Firebase Cloud Messaging reliably at scale, avoiding the pitfalls that trap most teams. In this article, we'll walk through token management, message types, and backend integration patterns we've used across e-commerce and SaaS platforms.
Firebase Cloud Messaging fundamentals
Firebase Cloud Messaging (FCM) is Google's infrastructure for reliable message delivery to Android devices, iOS, and web. It handles the complexity of routing messages through Google's servers, managing device connectivity, and retrying failed deliveries. Your app registers once, receives a token, and your backend sends messages through FCM's REST API or Admin SDK.
The flow is straightforward: device requests a registration token from FCM, your app sends that token to your backend, your backend stores it, and later your backend calls FCM's API to send a message targeting that token. FCM queues the message, waits for the device to come online, and delivers it. If the device is offline for too long, FCM discards the message based on time-to-live settings.
One critical insight from production: FCM is not a guaranteed delivery system. Messages can be lost if devices are offline too long, if the user uninstalls without cleanup, or if network conditions are poor. Design your app assuming some messages won't arrive, especially time-sensitive notifications like one-time passwords. For critical flows, combine push with polling or in-app state checks.
Register and refresh tokens
Your app must obtain a registration token before it can receive messages. In modern Android with Firebase SDK, registration is automatic, but you need to handle token refresh and store it on your backend.
Here's a typical setup in Kotlin:
import com.google.firebase.messaging.FirebaseMessaging
class TokenManager(private val apiClient: ApiClient) {
fun initializeTokenHandling() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
sendTokenToBackend(token)
}
}
}
private fun sendTokenToBackend(token: String) {
apiClient.registerDeviceToken(token).enqueue(object : Callback<Unit> {
override fun onResponse(call: Call<Unit>, response: Response<Unit>) {
if (response.isSuccessful) {
Log.d("TokenManager", "Token registered successfully")
}
}
override fun onFailure(call: Call<Unit>, t: Throwable) {
Log.e("TokenManager", "Failed to register token", t)
// Retry with exponential backoff
}
})
}
}This handles the initial token fetch, but tokens refresh occasionally when FCM decides (typically every 6 months, or when the user reinstalls or clears app data). You need to implement FirebaseMessagingService to catch refresh events:
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class MyMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("FCM", "Token refreshed: $token")
// Send new token to backend immediately
sendTokenToBackend(token)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Handle incoming message
if (remoteMessage.notification != null) {
showNotification(remoteMessage.notification!!)
}
if (remoteMessage.data.isNotEmpty()) {
handleDataPayload(remoteMessage.data)
}
}
private fun sendTokenToBackend(token: String) {
// Implementation similar to above
}
}In production, we've found that token registration often fails due to network issues. Implement a retry mechanism with exponential backoff and store the token locally until the backend acknowledges receipt. This prevents your app from trying to send new tokens on every app launch.
Data vs notification messages
FCM supports two message types, and understanding the difference is crucial for correct behavior:
- Notification messages: FCM automatically displays them as system notifications. The OS shows a notification in the status bar; the user doesn't need your app open. Limited to predefined fields (title, body, icon). Simple but inflexible.
- Data messages: Sent as a payload map that your app receives in
onMessageReceived. Your app controls how to handle them—show a custom notification, update UI, log analytics, or silently process. More powerful but your app must handle them.
The catch: if your app is in the foreground and receives a notification message, it still goes to onMessageReceived instead of being auto-displayed. If your app is backgrounded or closed, FCM auto-displays it. This inconsistency has burned many teams.
Here's how we handle it in practice. We send data messages and handle display ourselves:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val title = data["title"] ?: "App"
val body = data["body"] ?: ""
val deepLink = data["deepLink"]
if (isAppInForeground()) {
// App is open; update UI directly
EventBus.post(NotificationEvent(title, body, deepLink))
} else {
// App is backgrounded; show system notification
showSystemNotification(title, body, deepLink)
}
}
private fun isAppInForeground(): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val runningAppProcesses = activityManager.runningAppProcesses ?: return false
return runningAppProcesses.any { it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
}Data messages also work when the app is killed, so your onMessageReceived is still called. Notification messages, if sent while the app is killed, won't call onMessageReceived—they go straight to the system notification tray.
For reliability, avoid mixing both types in one message. Pick one strategy and stick to it across your backend. We recommend data messages for full control, especially if you need custom layouts or conditional display logic.
Send from your backend with service credentials
Your backend authenticates to FCM using a service account. Firebase creates this automatically when you set up your project, and you download the key as JSON.
With the Admin SDK (available for Node.js, Python, Go, Java), sending is straightforward. Here's a NestJS example:
import * as admin from 'firebase-admin';
@Injectable()
export class NotificationService {
constructor() {
const serviceAccount = require('./firebase-key.json');
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
}
async sendToToken(token: string, title: string, body: string, data?: Record<string, string>) {
try {
const message = {
notification: { title, body },
data: data || {},
token,
};
const response = await admin.messaging().send(message);
console.log('Message sent:', response);
return response;
} catch (error) {
console.error('Failed to send message:', error);
throw error;
}
}
async sendMulticast(tokens: string[], title: string, body: string) {
const message = {
notification: { title, body },
tokens,
};
const response = await admin.messaging().sendMulticast(message);
console.log(`Sent to ${response.successCount}, failed: ${response.failureCount}`);
// Track invalid tokens for cleanup
response.responses.forEach((resp, idx) => {
if (!resp.success) {
console.log(`Invalid token: ${tokens[idx]}`);
// Remove from your database
}
});
}
}A few production lessons here. First, keep your service account key secure—never commit it to version control. Use environment variables or a secrets manager. Second, handle invalid token responses. When FCM says a token is invalid, remove it from your database immediately; reusing invalid tokens just wastes API calls and slows down your backend.
Third, implement rate limiting. FCM has soft limits on send rates; if you send too many messages in quick succession, FCM may delay delivery. For bulk notifications, use the multicast API and batch requests in groups of 500. Fourth, log message IDs and track delivery—the response includes a message ID you can correlate with your database records for debugging and analytics.
Common mistakes
We've seen several patterns in production that cause grief:
- Ignoring token refresh. Many apps register once and never handle
onNewToken. Weeks later, all messages fail silently because tokens expired. Always implement the refresh handler. - Mixing notification and data message strategies. Sending both types in one message or switching between them causes your app to handle messages inconsistently. Pick one and standardize across your backend.
- No cleanup of invalid tokens. Dead tokens accumulate in your database. Each one wastes a backend API call. Aggressively remove tokens that FCM reports as invalid.
- Storing tokens without user context. Store the token with the user ID and device ID so you know who to notify and can handle duplicate tokens from the same user across devices.
- Assuming 100% delivery. Messages are lost. Build your app to not rely on push for critical flows; pair it with polling or in-app state checks for high-importance notifications.
- Testing only on emulator or always-online devices. Test on real devices with network toggling, airplane mode, and app killing to see how your app behaves. Background delivery differs significantly from foreground.
Key takeaways
- Implement
onNewTokeninFirebaseMessagingServiceto handle token refresh, and send the new token to your backend immediately with retry logic. - Use data messages for control and consistency; handle display and routing in your app. Notification messages are simpler but less flexible.
- Remove invalid tokens from your database promptly when FCM reports them; reusing dead tokens wastes backend resources and complicates debugging.
- Send from your backend using the Firebase Admin SDK with a service account key stored securely in environment variables.
- Batch multicast sends in groups of 500 and monitor delivery via message IDs; don't assume all messages arrive.
- Test on real devices with network interruptions and backgrounding to catch issues that emulators hide.






Comments
Loading comments…