Push Notifications & Background Services

Handling FCM in the Background and Terminated State

16 min Lesson 3 of 11

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.

Note: The FlutterFire 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 onBackgroundMessage handler 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());
}
Warning: The background handler runs in a separate Dart isolate with no access to your app's UI, plugin channels that require the main isolate, or any state set up in 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());
}
Tip: Call 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 call FirebaseMessaging.instance.setForegroundNotificationPresentationOptions() to control whether the notification banner appears while the app is in the foreground.
  • getInitialMessage() returns null on both platforms if the app was opened normally (not via a notification tap). Always null-check before accessing the message.
Key Takeaway: Use 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.