The Persistent Push Notification Problem

Every developer building a Flutter app eventually grapples with push notifications. The common frustration? Notifications work flawlessly when the app is open, but disappear into the digital ether when it’s in the background or terminated. This isn't a minor inconvenience; it’s a core feature that impacts user engagement and app utility. This walkthrough provides a comprehensive solution for Flutter push notifications using Firebase Cloud Messaging (FCM), addressing the complexities of foreground, background, and terminated states. We’ll tackle the specific challenges of 2026: Android 13’s runtime notification permissions, the modern FCM HTTP v1 API, and the often-overlooked background message handler.

Setting Up Dependencies

First, ensure your project is configured with Firebase. Then, you’ll need to add the necessary dependencies to your pubspec.yaml file. This includes firebase_core for foundational Firebase services and firebase_messaging for handling FCM messages.

dependencies
  flutter:
    sdk: flutter
  firebase_core:
    ^3.8.0
  firebase_messaging:
    ^14.7.0

After adding these, run flutter pub get to install them. You’ll also need to configure your Firebase project and link it to your Flutter app, following the official Firebase documentation for Flutter.

Handling Android 13+ Notification Permissions

Android 13 introduced a runtime permission for sending notifications. Unlike older versions where the permission was granted implicitly when the app was installed, users must now explicitly grant permission for your app to display notifications. This requires a slight modification to your Android-specific code.

In your android/app/src/main/java/[your_package_name]/MainActivity.java file, you need to request the POST_NOTIFICATIONS permission. A common approach is to request this permission when the app launches or when the user first encounters a feature that relies on notifications.

The firebase_messaging package provides utilities to check the current notification permission status. You can use FirebaseMessaging.instance.requestPermission(), which handles the platform-specific logic. It's crucial to call this method before attempting to subscribe to topics or expect foreground notifications to be displayed reliably. If the user denies the permission, your app should gracefully handle this by informing the user why notifications are important and perhaps guiding them to the app settings to enable them.

Implementing the FCM HTTP v1 API

Firebase Cloud Messaging has transitioned from its legacy HTTP API to the more robust and feature-rich HTTP v1 API. This new API offers better control, improved authentication, and enhanced payload capabilities. For backend integrations, especially those not using the Firebase Admin SDK directly, understanding and implementing the v1 API is essential.

The v1 API requires authentication using OAuth 2.0. You’ll need to generate a service account key from your Firebase project settings. Your server will use this key to obtain an access token, which is then included in the Authorization: Bearer [ACCESS_TOKEN] header of your HTTP requests to the FCM endpoint (https://fcm.googleapis.com/v1/projects/[PROJECT_ID]/messages:send).

The payload structure is also more defined. It supports sending messages to specific device tokens, topics, or conditions. For instance, sending a message to a specific device token would look something like this:

{
  "message": {
    "token": "DEVICE_REGISTRATION_TOKEN",
    "notification": {
      "title": "Hello from FCM v1!",
      "body": "This is a test notification."
    },
    "android": {
      "priority": "high"
    }
  }
}

While the Firebase Admin SDK for Node.js, Python, and Java abstracts much of this complexity, direct HTTP requests are necessary for other backend environments or custom solutions. Developers need to be aware of token expiration and refresh mechanisms for seamless operation.

Mastering Background and Terminated States

The true test of a push notification implementation lies in its behavior when the app isn't actively in use. FCM messages are categorized into two types: notification messages (which display automatically in the system tray) and data messages (which are handled by your app's code). FCM automatically displays notification messages when the app is in the background or terminated. However, to process the payload, display custom notifications, or trigger specific actions, you need a background message handler.

The firebase_messaging package provides the onBackgroundMessage handler. This is a top-level function that must be defined outside of any class. It’s called when a message is received while the app is in the background or terminated. This handler is crucial for handling data messages and for custom logic, like updating local data or scheduling local notifications.

Diagram illustrating Flutter FCM message handling in foreground, background, and terminated states

Here’s an example of how to define this handler in your main.dart file:

Future<void> onBackgroundMessage(RemoteMessage message) async {
  // Handle the background message here.
  // You can display a local notification, update data, etc.
  print('Got a background message: ${message.notification?.title}');

  // Example: Show a local notification for the background message
  await _showLocalNotification(message);
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.current
  );

  // Set the background message handler
  FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);

  // Request notification permissions for iOS and Android 13+
  await FirebaseMessaging.instance.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );

  // Get the FCM token for the device
  final fcmToken = await FirebaseMessaging.instance.getToken(vapidKey: 'YOUR_VAPID_KEY');
  print('FCM Token: $fcmToken');

  runApp(MyApp());
}

Future<void> _showLocalNotification(RemoteMessage message) async {
  // Implement your local notification logic here using a package like flutter_local_notifications
  print('Showing local notification for: ${message.notification?.title}');
}

When the app is in the foreground, you need to use the onMessage stream from FirebaseMessaging.instance to listen for incoming messages and handle them, typically by displaying an in-app notification or updating the UI. For terminated states, the onBackgroundMessage handler is invoked when the user taps the notification that brought the app out of termination.

Foreground Message Handling

Handling messages when the app is in the foreground requires actively listening to the onMessage stream. This stream emits RemoteMessage objects as they arrive. You can then use this data to update your UI, show a custom in-app banner, or trigger other actions without relying on the system tray notification.

FirebaseMessaging.instance.onMessage.listen((RemoteMessage message) async {
  print('Got a message whilst in the foreground!');
  print('Message data: ${message.data}');

  // Display a custom in-app notification or update UI
  if (message.notification != null) {
    print('Message also contains a notification: ${message.notification!.body}');
    // Use a package like flutter_local_notifications to show a banner
  }
});

This stream is active only when the app is in the foreground. When the app is backgrounded, the onBackgroundMessage handler takes over. Properly managing these two handlers ensures a seamless notification experience regardless of the app’s state.

Conclusion: A Unified Approach

Implementing push notifications in Flutter involves navigating platform-specific requirements and understanding the lifecycle of messages within FCM. By correctly configuring dependencies, requesting Android 13+ permissions, utilizing the FCM HTTP v1 API for backend communication, and implementing both foreground (onMessage) and background (onBackgroundMessage) handlers, you can build a robust notification system. This comprehensive approach ensures users receive timely and relevant updates, significantly enhancing app engagement and functionality.