Handling FCM in the Background and Terminated State
Handling FCM in the Background and Terminated State
When an FCM (Firebase Cloud Messaging) notification arrives, your Flutter app may be in one of three lifecycle states: foreground (running and visible), background (minimised but still running), or terminated (fully closed, not in memory). Foreground delivery is straightforward — FirebaseMessaging.onMessage fires and you handle it inside a live widget. The challenge lies in correctly handling messages when the app is backgrounded or terminated, and then navigating to the right screen when the user taps the notification.
firebase_messaging package version 14+ changes how the background isolate handler is registered. Always check the installed package version and its migration guide before following older tutorials.The Three Delivery Scenarios
- Foreground:
FirebaseMessaging.onMessage.listen(...)— app is open and active. - Background:
FirebaseMessaging.onBackgroundMessage(handler)— app process is alive but the UI is not visible. The handler runs in a separate Dart isolate. - Terminated: Same
onBackgroundMessagehandler is used. The OS starts a new Dart isolate specifically for the background handler; the main isolate (your UI) is not started. - Notification tap (background):
FirebaseMessaging.onMessageOpenedApp.listen(...)fires when the user taps a notification while the app is backgrounded and the app comes to the foreground. - Notification tap (terminated):
FirebaseMessaging.instance.getInitialMessage()returns the message that launched the app from a fully terminated state.
Registering the Background Handler
The background message handler must be a top-level function (not a class method, not an anonymous function, and not a closure). It must also be annotated with @pragma('vm:entry-point') to prevent the Dart AOT compiler from tree-shaking it away in release builds. Register it with FirebaseMessaging.onBackgroundMessage() before runApp() is called.
Top-Level Background Handler
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
// MUST be a top-level function — not inside a class or main()
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// If you need Firebase services here, initialise them first:
// await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
print('Background message received: ${message.messageId}');
print('Notification title: ${message.notification?.title}');
print('Data payload: ${message.data}');
// Perform lightweight work — do NOT show UI or use BuildContext here.
// Heavy tasks should be handed off to a background service or WorkManager.
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Register BEFORE runApp — this is mandatory
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}
main(). If you call a plugin that has not been initialised in the background isolate, you will get a MissingPluginException. Always call Firebase.initializeApp() inside the handler if you need other Firebase services.Responding to Notification Taps
Detecting that a message arrived is only half the story. The real challenge in production apps is navigating the user to the correct screen when they tap a notification. You need two separate code paths depending on whether the app was backgrounded or terminated:
onMessageOpenedApp and getInitialMessage
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
_setupInteractedMessage();
}
// Handle notification taps
Future<void> _setupInteractedMessage() async {
// Case 1: App was TERMINATED — launched by tapping a notification
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_handleMessageNavigation(initialMessage);
}
// Case 2: App was BACKGROUNDED — brought to foreground by tapping a notification
FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageNavigation);
}
void _handleMessageNavigation(RemoteMessage message) {
final String? screen = message.data['screen'];
if (screen == 'order_details') {
final String? orderId = message.data['order_id'];
Navigator.of(context).pushNamed('/orders/details', arguments: orderId);
} else if (screen == 'chat') {
Navigator.of(context).pushNamed('/chat');
} else {
Navigator.of(context).pushNamed('/notifications');
}
}
@override
Widget build(BuildContext context) => const MaterialApp(home: HomeScreen());
}
getInitialMessage() only once, typically in initState() of your root widget. Calling it multiple times is safe — it always returns the same message until the next app launch — but it is wasteful. After the first successful read, cache the result so you do not repeat async calls on hot restarts during development.Routing with a NavigatorKey
When getInitialMessage() or onMessageOpenedApp fires, the widget tree may not yet be fully built, and BuildContext may be unavailable. The recommended solution is to provide a GlobalKey<NavigatorState> to MaterialApp and navigate through it directly, bypassing the need for a BuildContext.
Using a GlobalKey for Context-Free Navigation
// Declare globally
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
// Safe to call here — navigatorKey is attached by the time
// the first frame is drawn, and getInitialMessage() is async.
_setupInteractedMessage();
}
Future<void> _setupInteractedMessage() async {
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) _navigate(initialMessage);
FirebaseMessaging.onMessageOpenedApp.listen(_navigate);
}
void _navigate(RemoteMessage message) {
navigatorKey.currentState?.pushNamed(
'/detail',
arguments: message.data,
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey, // attach the key
home: const HomeScreen(),
);
}
}
Platform Differences: iOS vs Android
- On Android, the background handler runs in a separate isolate even when the app is partially alive, and the system shows the notification automatically for notification messages. Data-only messages must be handled entirely by your handler.
- On iOS, you must request user permission with
FirebaseMessaging.instance.requestPermission()or no notifications will be delivered. You should also callFirebaseMessaging.instance.setForegroundNotificationPresentationOptions()to control whether the notification banner appears while the app is in the foreground. getInitialMessage()returnsnullon both platforms if the app was opened normally (not via a notification tap). Always null-check before accessing the message.
FirebaseMessaging.onBackgroundMessage() with a top-level, @pragma-annotated handler to process messages when the app is not in the foreground. Use onMessageOpenedApp to detect notification taps from the background state, and getInitialMessage() to detect taps from the terminated state. Together, these three APIs cover every possible notification-launch scenario.