Backend Integration

Flutter Push Notifications with FCM: The Complete 2026 Implementation Guide

Muhammad Shakil Muhammad Shakil
Jul 10, 2025
22 min read
Flutter Push Notifications with FCM: The Complete 2026 Implementation Guide
Back to Blog

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:

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 automatically

Add these dependencies to your pubspec.yaml:

dependencies:
 firebase_core: ^3.8.1
 firebase_messaging: ^15.2.1
 flutter_local_notifications: ^18.0.1

I 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 console

Step 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.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.