Last year I shipped a food delivery app where push notifications were the lifeline of the entire business. Order confirmations, driver assignments, delivery ETAs — everything flowed through Firebase Cloud Messaging. Two weeks after launch, I got a panicked call: "Customers aren't getting their order updates." Turns out, the background handler was silently crashing because I'd referenced a singleton that didn't exist in the background isolate. No error. No crash report. Just silence. That one bug taught me more about FCM than any tutorial ever could.
This guide covers everything I've learned from implementing push notifications in 30+ Flutter apps. Not the "add firebase_messaging and call it done" version — the real production version where notifications need to work reliably across Android, iOS, foreground, background, and terminated states. If you've already built your app with clean architecture and need to wire up notifications properly, this is where I'd start.
Why Push Notifications Break in Production
Push notifications are deceivingly simple in development. You follow a tutorial, send a test message from the Firebase Console, see it pop up on your device, and think you're done. Then production happens.
The problems I see repeatedly across client projects:
- Background handlers crash silently — your Dart code runs in a separate isolate with no access to your app's state, providers, or dependency injection container
- Android 13+ users never see notifications — because nobody requested the runtime permission
- iOS notifications vanish — because APNs credentials expired or the provisioning profile doesn't include the push entitlement
- Notification taps do nothing — because deep linking wasn't wired for the terminated state, only background
- Tokens go stale — devices that haven't opened the app in months still get targeted, wasting your FCM quota
Every single one of these has bitten me in production. I'll show you how to handle all of them.
FCM Architecture — How It Actually Works
Before writing any code, understanding FCM's message flow saves hours of debugging later. Here's what happens when you send a push notification:
Your backend (or Cloud Function) sends a message to the FCM API. FCM routes it through Google's infrastructure, then delivers it via platform-specific channels: APNs for iOS and FCM's own channel for Android. The device receives the message and your app handles it differently depending on its state.
Three App States Matter
Foreground: App is visible and active — your onMessage stream fires.
Background: App is running but not visible — onBackgroundMessage fires
in a separate isolate. Terminated: App was killed — the system shows the notification
and getInitialMessage() fires when the user taps it to cold-start the app.
The critical thing most guides skip: FCM has two message types — notification messages and
data messages. Notification messages have a title and body that
the system tray handles automatically. Data messages are raw key-value payloads your app processes entirely
in code. This distinction changes how your background handler works, and getting it wrong is probably the
number one cause of "my notifications aren't showing" bugs.
Project Setup with FlutterFire CLI
I used to configure Firebase manually — downloading google-services.json, editing
build.gradle, adding GoogleService-Info.plist. The FlutterFire CLI
eliminated all of that. Here's the setup I use for every project:
# Install FlutterFire CLI (one time)
dart pub global activate flutterfire_cli
# Configure your project
flutterfire configure --project=your-firebase-project-id
# This generates firebase_options.dart automaticallyAdd these dependencies to your pubspec.yaml:
dependencies:
firebase_core: ^3.8.1
firebase_messaging: ^15.2.1
flutter_local_notifications: ^18.0.1I pair firebase_messaging with flutter_local_notifications in every single project. The Firebase package handles the FCM connection and token management. The local notifications package handles how notifications actually display — custom sounds, action buttons, images, and Android notification channels. You need both.
Initialize Firebase before anything else in your main():
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Handle background message — keep this lightweight
debugPrint('Background message: ${message.messageId}');
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Register the background handler BEFORE runApp
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}The Background Handler Must Be Top-Level
That _firebaseMessagingBackgroundHandler function cannot be a method on a class, a closure,
or nested inside another function. It must be a top-level or static function. It runs in its own
Dart isolate, so
it has no access to your widget tree, providers, service locator, or any app state. I spent two full
days tracking this bug in my first FCM project — everything worked in foreground but background was
completely dead.
Permission Handling — iOS and Android 13+
This section didn't used to matter much. Before Android 13, notifications just worked. Now both platforms require explicit permission, and if you skip this step, your users will never see a single notification.
class NotificationPermissionHandler {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
Future<bool> requestPermission() async {
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false, // Set true for iOS quiet notifications
announcement: false,
carPlay: false,
criticalAlert: false,
);
switch (settings.authorizationStatus) {
case AuthorizationStatus.authorized:
debugPrint('User granted full notification permission');
return true;
case AuthorizationStatus.provisional:
debugPrint('User granted provisional permission');
return true;
case AuthorizationStatus.denied:
debugPrint('User denied notification permission');
return false;
case AuthorizationStatus.notDetermined:
debugPrint('Permission not yet requested');
return false;
}
}
}On iOS, setting provisional: true lets you deliver "quiet" notifications to the notification
center without an alert — the user sees them when they pull down, but their phone doesn't buzz. I use
provisional permissions for non-urgent content like weekly digests. For anything time-sensitive like the
order updates in that food delivery app, you want full authorization.
On Android 13 (API 33+), you also need to declare the permission in
AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- ... rest of manifest -->
</manifest>When to Ask for Permission
Never request notification permission on first app launch. Users who haven't experienced your app's value will deny it reflexively. I ask after the user completes their first meaningful action — placing an order, saving a favorite, completing onboarding. Conversion rates for permission dialogs jump from ~40% to ~75% when you time it right. If they deny, I show a custom in-app message explaining why notifications matter and offer a button to open system settings.
Foreground Notification Display
When your app is in the foreground, FCM delivers the message to your Dart code via the
onMessage stream, but it does not show anything in the system tray by default.
This catches everyone off guard. The user is staring at your app, a message arrives, and nothing visible
happens.
You need flutter_local_notifications to bridge this gap. Here's the setup I use in production:
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
class NotificationService {
static final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
static Future<void> initialize() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_notification');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTap,
);
// Listen for foreground messages
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
}
static Future<void> _handleForegroundMessage(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) return;
await _localNotifications.show(
message.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
'high_importance_channel',
'High Importance Notifications',
channelDescription: 'Used for critical app notifications',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_notification',
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: message.data['route'],
);
}
static void _onNotificationTap(NotificationResponse response) {
final route = response.payload;
if (route != null && route.isNotEmpty) {
// Navigate using your router — I use GoRouter
navigatorKey.currentState?.pushNamed(route);
}
}
}Notice I'm setting requestAlertPermission: false on iOS. That's because I handle permissions
separately through FirebaseMessaging.requestPermission(), not through the local notifications
plugin. Requesting permission through both creates a confusing double-prompt situation — I've seen this trip
up junior developers multiple times. If you're managing state across your app, this pattern fits well with
the state management approaches I covered
previously.
The Background Handler — Getting It Right
The background handler is where most FCM implementations fall apart. When a message arrives and your app
is in the background or terminated, Flutter spawns a headless isolate specifically for your handler. This
isolate has no BuildContext, no widget tree, no GetIt, no Riverpod providers —
nothing from your app's runtime exists there.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// You MUST re-initialize Firebase in the background isolate
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Keep processing minimal — the OS can kill long-running handlers
final type = message.data['type'];
switch (type) {
case 'order_update':
await _updateLocalOrderCache(message.data);
break;
case 'chat_message':
await _incrementUnreadBadge();
break;
default:
debugPrint('Unhandled background message type: $type');
}
}
Future<void> _updateLocalOrderCache(Map<String, dynamic> data) async {
// Use a lightweight storage solution — SharedPreferences or Hive
final prefs = await SharedPreferences.getInstance();
final orderId = data['order_id'] as String?;
final status = data['status'] as String?;
if (orderId != null && status != null) {
await prefs.setString('order_${orderId}_status', status);
}
}
Future<void> _incrementUnreadBadge() async {
final prefs = await SharedPreferences.getInstance();
final current = prefs.getInt('unread_count') ?? 0;
await prefs.setInt('unread_count', current + 1);
}That @pragma('vm:entry-point') annotation is mandatory since Flutter 3.3. Without it, the
Dart compiler's tree-shaking will strip the function because nothing in your main isolate references it
directly. Your app compiles fine, tests pass, but the background handler simply never fires in release
builds. I had a client call me about this exact issue — debug mode worked perfectly, release mode was
completely silent. Took me an embarrassing amount of time to figure out the pragma was missing.
Background Handler Security
Never perform authentication-dependent operations in the background handler. Token refresh, API calls with user credentials, database writes that require auth — none of these are safe in the background isolate because you can't guarantee your auth state is current. Write to local storage and sync when the app returns to foreground. This pattern is especially important when you're handling app security properly.
Android Notification Channels
Android 8.0+ requires notification channels. If you don't create one, your notifications go into a default channel that users can't configure individually. For production apps, I always create separate channels for different notification types:
class AndroidChannelManager {
static final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
static Future<void> createChannels() async {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin == null) return;
// Critical notifications — orders, payments, security alerts
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
'critical_channel',
'Critical Alerts',
description: 'Order updates, payment confirmations, and security alerts',
importance: Importance.high,
playSound: true,
enableVibration: true,
),
);
// Social notifications — messages, comments, likes
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
'social_channel',
'Social Updates',
description: 'Messages, comments, and social activity',
importance: Importance.defaultImportance,
playSound: true,
),
);
// Marketing — promotions, offers, news
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
'marketing_channel',
'Promotions & News',
description: 'Special offers, app updates, and news',
importance: Importance.low,
playSound: false,
),
);
}
}Call createChannels() once during app initialization, before any notifications can arrive. The
channel ID you use when displaying a notification must match one of these channels. If it doesn't, Android
falls back to the default channel and in some OEM implementations that means the notification just doesn't
show. I've debugged this on Xiaomi and Samsung devices where notifications vanished because of a channel ID
typo.
Deep Linking from Notifications
A notification that opens your app's home screen is almost useless. Users expect to land exactly where the notification tells them to go — the specific order, the chat thread, the product page. Here's the deep linking flow I use with GoRouter:
class NotificationRouter {
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
static Future<void> initialize() async {
// Handle notification tap when app was terminated (cold start)
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_handleNotificationNavigation(initialMessage.data);
}
// Handle notification tap when app is in background
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleNotificationNavigation(message.data);
});
}
static void _handleNotificationNavigation(Map<String, dynamic> data) {
final route = data['route'] as String?;
final id = data['id'] as String?;
if (route == null) return;
switch (route) {
case 'order_detail':
navigatorKey.currentContext?.goNamed('orderDetail', pathParameters: {'id': id ?? ''});
break;
case 'chat':
navigatorKey.currentContext?.goNamed('chat', pathParameters: {'threadId': id ?? ''});
break;
case 'product':
navigatorKey.currentContext?.goNamed('product', pathParameters: {'productId': id ?? ''});
break;
default:
navigatorKey.currentContext?.go('/');
}
}
}The most common mistake I see: developers wire up onMessageOpenedApp and forget about
getInitialMessage(). When a user taps a notification and the app was fully killed (terminated
state), only getInitialMessage() fires — and it fires exactly once. If you miss it, the user
opens the app, lands on the home screen, and has no idea which order they were supposed to check.
Navigation Timing Matters
Don't navigate immediately in getInitialMessage(). Your router, auth state, and widget
tree might not be ready yet during a cold start. I store the pending navigation data and process it
after the first frame renders using WidgetsBinding.instance.addPostFrameCallback. This
avoids the "Navigator not mounted" errors that plague cold-start deep linking.
Data Messages vs Notification Messages
This distinction tripped me up for months before I truly understood it. FCM messages come in two flavors, and they behave completely differently depending on your app's state:
Notification messages have a notification payload with title
and body. When the app is in background or terminated, the OS displays them automatically
and your Dart onBackgroundMessage handler may not fire at all (depends on whether you also
included a data payload).
Data messages have only a data payload. Your app is 100% responsible for
handling and displaying them. Your background handler always fires. You get full control.
// Notification message — OS handles display automatically
{
"message": {
"token": "device_fcm_token",
"notification": {
"title": "Order Shipped",
"body": "Your order #1234 is on the way!"
},
"data": {
"route": "order_detail",
"id": "1234"
}
}
}
// Data-only message — your handler controls everything
{
"message": {
"token": "device_fcm_token",
"data": {
"type": "order_update",
"title": "Order Shipped",
"body": "Your order #1234 is on the way!",
"route": "order_detail",
"id": "1234"
}
}
}For production apps, I almost always use data-only messages and display them with flutter_local_notifications. This gives me full control over notification display, custom channel routing, conditional suppression (don't show a chat notification if the user is already in that chat), and consistent behavior across all three app states. The slight overhead of handling display yourself pays off massively when your PM asks "can we not show the notification if the user is already on that screen?" — something you can't do with system-handled notification messages.
Topic-Based Messaging and User Segments
Not every notification should go to every user. FCM topics let you group users without managing individual tokens on your backend. I use topics for broadcast categories and token-based messaging for user-specific notifications:
class TopicManager {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
/// Subscribe user to relevant topics based on their preferences
Future<void> syncTopics(UserPreferences prefs) async {
// Always subscribe to app-wide announcements
await _messaging.subscribeToTopic('app_updates');
// Category-based subscriptions
if (prefs.wantsPromotions) {
await _messaging.subscribeToTopic('promotions');
} else {
await _messaging.unsubscribeFromTopic('promotions');
}
if (prefs.wantsNewsAlerts) {
await _messaging.subscribeToTopic('news_${prefs.preferredLanguage}');
}
// Location-based topics for geo-relevant content
if (prefs.city != null) {
await _messaging.subscribeToTopic('city_${prefs.city!.toLowerCase()}');
}
}
/// Unsubscribe from all topics on logout
Future<void> clearAllTopics() async {
await _messaging.unsubscribeFromTopic('app_updates');
await _messaging.unsubscribeFromTopic('promotions');
// Unsubscribe from any locale/city topics too
}
}For the banking app I built, topics worked great for regulatory announcements that went to all users. But transaction alerts had to be token-based — you don't want someone else's deposit notification showing up on your phone. The rule I follow: if the message content is the same for everyone in the group, use topics. If it's personalized or contains sensitive data, use direct token messaging through your Cloud Functions backend.
Server-Side Notifications with Cloud Functions
Sending notifications from Cloud Functions is where FCM becomes genuinely powerful. Instead of triggering notifications manually, you react to events — a new Firestore document, a Realtime Database change, a Pub/Sub message — and send the right notification to the right user automatically.
// Cloud Function (TypeScript) — send notification on order status change
import * as functions from 'firebase-functions/v2';
import * as admin from 'firebase-admin';
admin.initializeApp();
export const onOrderStatusChange = functions.firestore
.onDocumentUpdated('orders/{orderId}', async (event) => {
const before = event.data?.before.data();
const after = event.data?.after.data();
if (!before || !after) return;
if (before.status === after.status) return;
const userId = after.userId as string;
const orderId = event.params.orderId;
const newStatus = after.status as string;
// Fetch user's FCM token from their profile
const userDoc = await admin.firestore()
.collection('users')
.doc(userId)
.get();
const fcmToken = userDoc.data()?.fcmToken;
if (!fcmToken) return;
const statusMessages: Record<string, string> = {
'confirmed': 'Your order has been confirmed!',
'preparing': 'Your order is being prepared',
'shipped': 'Your order is on the way!',
'delivered': 'Your order has been delivered',
};
const body = statusMessages[newStatus] || `Order updated to: ${newStatus}`;
// Send data-only message for full client control
await admin.messaging().send({
token: fcmToken,
data: {
type: 'order_update',
title: `Order #${orderId}`,
body: body,
route: 'order_detail',
id: orderId,
status: newStatus,
},
android: {
priority: 'high',
},
apns: {
payload: {
aps: {
'content-available': 1,
},
},
},
});
});FCM HTTP v1 API
The legacy FCM server key API was deprecated in June 2024. Always use the HTTP v1 API which uses OAuth 2.0 service account credentials. If your backend
still uses server_key= in the header, migrate now — the legacy API will stop working. I
migrated three client projects in 2024 and the v1 API is actually cleaner to work with.
Notice I'm setting priority: 'high' on Android and content-available: 1 on
iOS. High priority on Android wakes the device from Doze mode. Content-available on iOS triggers your
background handler even for data-only messages. Without these, your notifications might arrive minutes
late or not at all when the device is idle — which completely defeats the purpose for time-sensitive
alerts like that food delivery app.
Token Management and Refresh
FCM tokens are ephemeral. They change when the user reinstalls the app, clears data, restores from a backup, or when Firebase rotates them. If your backend sends a message to a stale token, FCM returns an error and the notification never arrives. I've seen production apps with 40% stale tokens in their database because nobody handled token refresh properly.
class FCMTokenManager {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
Future<void> initializeToken(String userId) async {
// Get current token and save it
final token = await _messaging.getToken();
if (token != null) {
await _saveToken(userId, token);
}
// Listen for token refresh events
_messaging.onTokenRefresh.listen((newToken) {
_saveToken(userId, newToken);
});
}
Future<void> _saveToken(String userId, String token) async {
await _firestore.collection('users').doc(userId).set({
'fcmTokens': {
_getDeviceId(): {
'token': token,
'updatedAt': FieldValue.serverTimestamp(),
'platform': Platform.isIOS ? 'ios' : 'android',
},
},
}, SetOptions(merge: true));
}
String _getDeviceId() {
// Use a package like device_info_plus for a stable device identifier
// Or generate a UUID on first install and store in SharedPreferences
return 'device_${DateTime.now().millisecondsSinceEpoch}';
}
Future<void> removeToken(String userId) async {
// Call this on logout to prevent phantom notifications
final token = await _messaging.getToken();
if (token != null) {
await _firestore.collection('users').doc(userId).update({
'fcmTokens.${_getDeviceId()}': FieldValue.delete(),
});
}
await _messaging.deleteToken();
}
}I store tokens in a map keyed by device identifier, not as a single field. Users with multiple devices
need notification delivery to all of them. When they log out on one device, I only remove that device's
token — not all of them. And I always call deleteToken() on logout so the device stops
receiving notifications for the previous user. This is especially important for shared devices and is
part of the security practices I follow in every
project.
Clean Up Stale Tokens
Run a scheduled Cloud Function weekly that checks updatedAt timestamps. Any token not
refreshed in 60+ days is probably stale. When you try to send to a stale token, FCM returns a
messaging/registration-token-not-registered error — catch that and delete the token
from your database. I also delete tokens when the FCM API returns specific error codes during sends.
Rich Notifications — Images, Actions, and Sounds
Plain text notifications get the job done, but rich notifications with images and action buttons get significantly better engagement. I measured a 2.3x higher tap-through rate on the e-commerce app when notifications included a product thumbnail versus plain text. Here's how I implement them:
Future<void> showRichNotification(Map<String, dynamic> data) async {
final imageUrl = data['image_url'] as String?;
BigPictureStyleInformation? bigPicture;
if (imageUrl != null) {
try {
final response = await http.get(Uri.parse(imageUrl));
bigPicture = BigPictureStyleInformation(
ByteArrayAndroidBitmap.fromBase64String(
base64Encode(response.bodyBytes),
),
contentTitle: data['title'],
summaryText: data['body'],
);
} catch (e) {
// Fall back to text-only if image download fails
debugPrint('Failed to download notification image: $e');
}
}
await FlutterLocalNotificationsPlugin().show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
data['title'] as String?,
data['body'] as String?,
NotificationDetails(
android: AndroidNotificationDetails(
data['channel'] as String? ?? 'default_channel',
'Default Notifications',
importance: Importance.high,
priority: Priority.high,
styleInformation: bigPicture,
actions: <AndroidNotificationAction>[
const AndroidNotificationAction('view', 'View',
showsUserInterface: true),
const AndroidNotificationAction('dismiss', 'Dismiss'),
],
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
attachments: imageUrl != null
? [DarwinNotificationAttachment(imageUrl)]
: null,
),
),
payload: '${data["route"]}:${data["id"]}',
);
}Action buttons let users respond without opening the app. For the food delivery app, I added "Track Order" and "Call Driver" actions directly on the notification. For the e-commerce app with Stripe, "Buy Now" and "Save for Later" reduced the steps from notification to purchase by half. These small touches matter more than most developers think.
Testing Notifications Without Losing Your Mind
Testing push notifications is notoriously painful. You can't test them in unit tests. The iOS simulator doesn't support push at all. And the Firebase Console's test message feature is limited. Here's my testing workflow:
Step 1: Get a device token. Add a temporary button or log the token on app start:
// Temporary — remove before shipping
final token = await FirebaseMessaging.instance.getToken();
debugPrint('FCM Token: $token');
// Copy this from your debug consoleStep 2: Send test messages with cURL. The FCM HTTP v1 API accepts messages directly:
# Get an access token (requires gcloud CLI and a service account)
ACCESS_TOKEN=$(gcloud auth print-access-token)
# Send a data-only test message
curl -X POST \
"https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "PASTE_DEVICE_TOKEN_HERE",
"data": {
"type": "test",
"title": "Test Notification",
"body": "Testing deep link to order",
"route": "order_detail",
"id": "test-123"
}
}
}'Step 3: Test all three app states. Send the same cURL command with the app in foreground, then minimize the app and send again, then kill the app completely and send a third time. Each state has different code paths, and I've caught bugs by testing all three. The background and terminated states need extra attention because the handler runs in a different isolate — any assumptions about shared state break there. For more systematic approaches, read my testing strategy guide.
My FCM Testing Checklist
Before every notification-related deploy: (1) foreground display with tap navigation, (2) background display with tap navigation, (3) terminated cold-start with tap navigation, (4) permission denied scenario, (5) notification with image and action buttons, (6) topic message delivery, (7) token refresh handling. Takes about 20 minutes and has caught production bugs every single time.
Debugging the Most Common FCM Issues
After debugging notifications in 30+ apps, these are the issues I encounter most frequently. Saving you the hours I lost figuring each one out:
Notifications work in debug but not release: You're missing
@pragma('vm:entry-point') on the background handler. Dart's tree-shaker removes the
function in release mode because nothing in your main code explicitly calls it. Add the pragma and rebuild.
Notifications arrive but the background handler doesn't fire: You're sending notification
messages (with a notification payload) instead of data messages. When the app is in the
background, notification messages are handled by the OS, not your Dart code. Switch to data-only
messages for full control.
Notifications don't show on Xiaomi/Huawei/Samsung: OEM manufacturers add aggressive battery optimization that kills background processes. Your users need to disable battery optimization for your app. I show a one-time dialog linking to device-specific settings. The Don't Kill My App site has device-specific instructions that I reference in my apps.
iOS notifications stop after a few days: Your APNs authentication key or certificate expired. I always use authentication keys (p8 files) instead of certificates because keys don't expire. Check your Firebase project settings under Cloud Messaging to verify the APNs key is still valid. Apple's User Notifications documentation covers the provisioning requirements.
getInitialMessage() returns null even though I tapped the notification:
You're calling it too late. It must be called before any navigation occurs and before the splash screen
completes. I call it immediately after Firebase.initializeApp() and store the result.
Also, getInitialMessage() only returns a value once per cold start — if you accidentally
consume it in a lifecycle callback that runs multiple times, it's gone.
Enable FCM Debug Logging
On Android, run adb shell setprop log.tag.FirebaseMessaging DEBUG to see detailed FCM
logs in Logcat. On iOS, pass -FIRDebugEnabled as a launch argument in Xcode. These logs
show you exactly when tokens are generated, when messages arrive, and what delivery errors occur.
I keep these enabled during the entire notification development phase.
Production Notification Architecture
After shipping notifications in dozens of apps, I've settled on an architecture that handles everything from simple alerts to complex multi-channel notification systems. Here's the service layer I build for production:
/// Production notification service that handles all FCM lifecycle events
class ProductionNotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localPlugin =
FlutterLocalNotificationsPlugin();
// Stream controller for notification taps — consumed by your router
final StreamController<Map<String, dynamic>> _navigationStream =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get onNotificationTap => _navigationStream.stream;
Future<void> initialize() async {
// 1. Create Android notification channels
await _createChannels();
// 2. Initialize local notifications plugin
await _initializeLocalNotifications();
// 3. Check terminated-state deep link
final initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
_navigationStream.add(initialMessage.data);
}
// 4. Listen for background tap events
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_navigationStream.add(message.data);
});
// 5. Listen for foreground messages
FirebaseMessaging.onMessage.listen(_showForegroundNotification);
// 6. Set foreground presentation options for iOS
await _messaging.setForegroundNotificationPresentationOptions(
alert: false, // We handle display ourselves
badge: true,
sound: false,
);
}
Future<void> _createChannels() async {
final android = _localPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android == null) return;
const channels = [
AndroidNotificationChannel('orders', 'Order Updates',
description: 'Order status notifications',
importance: Importance.high),
AndroidNotificationChannel('messages', 'Messages',
description: 'Chat messages',
importance: Importance.high),
AndroidNotificationChannel('promotions', 'Promotions',
description: 'Deals and offers',
importance: Importance.low),
];
for (final channel in channels) {
await android.createNotificationChannel(channel);
}
}
Future<void> _initializeLocalNotifications() async {
await _localPlugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_notification'),
iOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: (response) {
final payload = response.payload;
if (payload != null) {
final parts = payload.split(':');
_navigationStream.add({'route': parts[0], 'id': parts.length > 1 ? parts[1] : ''});
}
},
);
}
Future<void> _showForegroundNotification(RemoteMessage message) async {
final data = message.data;
final channelId = _getChannelId(data['type']);
await _localPlugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
data['title'] ?? message.notification?.title ?? 'Notification',
data['body'] ?? message.notification?.body,
NotificationDetails(
android: AndroidNotificationDetails(
channelId, channelId,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(presentAlert: true, presentSound: true),
),
payload: '${data["route"]}:${data["id"]}',
);
}
String _getChannelId(String? type) {
switch (type) {
case 'order_update': return 'orders';
case 'chat_message': return 'messages';
case 'promo': return 'promotions';
default: return 'orders';
}
}
void dispose() {
_navigationStream.close();
}
}This service exposes a single onNotificationTap stream that your router listens to. Whether
the tap came from a foreground notification, a background notification, or a cold-start deep link, the
router receives the same data format. This keeps your routing logic in one place instead of scattered
across three different callbacks. I wire this into whatever state management the project uses — it works
equally well with Riverpod, Bloc, or plain Provider.
For performance, the service is intentionally lazy. Channel creation runs once. Token refresh is event-driven, not polled. The foreground listener only processes messages when the app is active. And the background handler (registered separately at the top level) stays minimal to avoid OEM battery killers shutting it down.
Track Notification Delivery
Use firebase_analytics to track notification opens. Log a custom event when
onNotificationTap fires with the notification type and route. This tells you which
notifications users engage with and which they ignore — critical data for tuning your notification
strategy over time. I review these metrics weekly with the product team.
If your app needs to work reliably without connectivity — like that food delivery app on spotty cellular — combine this notification architecture with an offline-first approach using Drift. Cache notification data locally so users can still view their order history even when the network drops. The background handler writes to local storage and the foreground syncs everything — same pattern, different storage layer.
For apps that need scheduled local notifications without FCM involvement — reminders, daily prompts,
countdown alerts — flutter_local_notifications handles that too. I use FCM for server-triggered
messages and local scheduling for user-configured reminders. The combination covers every notification use
case I've encountered across startup apps and enterprise
projects alike.
One more thing worth mentioning: if you're pairing notifications with a reactive UI, consider how your
reactive state management handles incoming notification
data. The stream-based architecture above integrates naturally with any reactive pattern — Riverpod's
StreamProvider, Bloc's event system, or even raw StreamBuilder widgets. The
notification service doesn't care what consumes its stream, which is exactly the kind of separation that
makes a clean architecture work in practice.
I've also seen teams try to handle everything with awesome_notifications as an all-in-one solution. It's a solid package — covers scheduled notifications, action buttons, and media attachments in one API. The tradeoff is that it's more opinionated and takes control of FCM token handling. For most projects I still prefer firebase_messaging plus flutter_local_notifications because it gives me more granular control, but awesome_notifications is a valid choice if you want faster setup with fewer moving parts.
Notifications are one of those features that seem simple but touch every layer of your app — from server-side triggers through background isolates to UI navigation. Get the foundation right with the patterns in this guide and you'll save yourself weeks of debugging. Every production notification bug I described here cost me real hours to solve the first time — now you don't have to repeat those mistakes.
Frequently Asked Questions
Why are my FCM notifications not showing on Android 13+?
Android 13 introduced a runtime permission: POST_NOTIFICATIONS. Your app needs to
explicitly request it before any notification can appear. I've been burned by this on three separate
production launches where everything worked on older devices but Android 13 phones showed nothing.
Add android.permission.POST_NOTIFICATIONS to your manifest and call
FirebaseMessaging.instance.requestPermission() early in your startup flow. If the user
denies it, show a custom dialog explaining why notifications matter and offer to open the system
settings page.
What's the difference between notification messages and data messages in FCM?
Notification messages include a notification key with title and
body that the system tray displays automatically. Data messages have only a
data key with your custom payload. The real difference is behavior: when the app is in
background, notification messages are handled entirely by the OS and may not trigger your Dart
background handler. Data messages always trigger your handler regardless of app state. I use data
messages with flutter_local_notifications in every production app so I control exactly how, when,
and where notifications display.
Why does my FCM background handler not fire?
Three things to check: (1) The handler must be a top-level function, not a class method or closure.
(2) It needs @pragma('vm:entry-point') so the Dart tree-shaker doesn't remove it in
release builds. (3) If you're sending notification messages instead of data messages, the OS handles
the notification directly and your handler may not fire. I spent two full days debugging this on my
first FCM project — debug mode worked perfectly but release was completely dead. The pragma was the
fix.
How do I handle deep linking from push notifications in Flutter?
Use FirebaseMessaging.onMessageOpenedApp for taps when the app is in the background,
and getInitialMessage() for taps that cold-start the app from a terminated state. Both
give you a RemoteMessage with the data payload. I put a route and
id in every notification payload and use GoRouter to navigate to the right screen.
The number one mistake I see: forgetting getInitialMessage(). It only fires once
during a cold start, and if you don't capture it immediately, the user opens the app and lands
on the home screen with no idea where to go.
Should I use firebase_messaging or awesome_notifications?
They solve different problems, so it's not really a choice between them. firebase_messaging handles the FCM connection, token management, and message receiving. flutter_local_notifications (or awesome_notifications) handles how the notification looks — action buttons, images, notification channels, custom sounds. I pair firebase_messaging with flutter_local_notifications in every project. awesome_notifications is also great if you want an all-in-one package with less boilerplate, but I prefer the granular control of the two-package approach.
How do I send push notifications to specific users in Flutter?
Store each user's FCM token in your database (I use Firestore) when they log in. When you need to notify a specific user, send a message to their token via the FCM HTTP v1 API or a Cloud Function. I store tokens in a map keyed by device identifier so multi-device users get notifications on all their devices. Always include a timestamp with the token so you can clean up stale tokens that haven't been refreshed in 60+ days — stale tokens silently waste your FCM sends.
Do FCM notifications work on iOS simulators?
No. Push notifications require APNs, which doesn't work on the simulator. You can test foreground data message handling (your onMessage stream fires), but the actual notification display requires a physical iOS device. I keep a dedicated test iPhone for notification development. For automated testing, I mock the FirebaseMessaging class and test the data handling separately — the UI display is a manual verification step.
How many topics can a device subscribe to in FCM?
There's no hard per-device limit documented by Google, but the practical recommendation is under 2,000 topics per app instance. Topic messages also have slightly higher delivery latency than direct token messages. I use topics for broadcast categories like "app_updates" and "breaking_news" where every subscriber gets the same message. For user-specific notifications — transaction alerts, personal messages, order updates — I always use direct token messaging through Cloud Functions. Mixing both is completely fine and is what most production apps end up doing.