Skip to main content

Solving Flutter FCM Background Notifications on Android 14

 There is no frustration quite like a push notification that works perfectly in debug mode but vanishes in a release build. You see the logs verify the payload was sent, Firebase Console reports a success, yet the device remains silent.

With the release of Android 13 (API 33) and the stricter policies of Android 14, the rules for background execution and notification delivery have changed fundamentally. If your Flutter app fails to wake up from a "Terminated" or "Background" state, it is likely due to a collision between Dart's isolate lifecycle and Android's battery optimization intent filters.

This guide provides a rigorous, production-ready implementation to guarantee FCM delivery on Android 14, covering the necessary native configuration, Dart entry points, and payload structuring.

The Root Cause: Why Notifications Fail on Android 14

To fix the issue, we must understand the architectural bottleneck. When a Flutter app is "terminated" (swiped away from the recents list) or in the background, the Flutter engine is detached from the UI.

1. The Missing VM Entry Point

In debug mode, the Dart VM is often kept alive or easily re-attached. In a compiled release build, the Dart code is tree-shaken and optimized. If the background message handler is not explicitly flagged to the compiler, it may be discarded or become inaccessible to the native Android bridge, causing the app to crash silently when a push arrives.

2. The Permission Wall (API 33+)

Android 13 introduced the runtime permission POST_NOTIFICATIONS. Unlike previous versions where notifications were opt-out, they are now strictly opt-in. If your app targets SDK 33+ and does not explicitly request this permission at runtime, the system automatically suppresses all notifications.

3. Payload Priority and Doze Mode

Android 14 aggressively manages battery life via "Doze" and "App Standby" buckets. Standard priority messages are often batched or delayed until the user wakes the device. To wake a terminated app immediately, specific "High Priority" headers are required in the FCM payload.


Step 1: Android Native Configuration

Before touching Dart code, the Android layer must be configured to accept the signals.

1. Update build.gradle

Ensure your app targets the latest API levels to interact correctly with the new permission models.

File: android/app/build.gradle

android {
    compileSdkVersion 34 // Or latest stable
    
    defaultConfig {
        // ... existing config
        minSdkVersion 23 // Recommended for modern Flutter plugins
        targetSdkVersion 34
    }
}

2. Configure AndroidManifest.xml

We need to declare the POST_NOTIFICATIONS permission and, crucially, setup the default notification channel. If an incoming notification lacks a channel ID, Android 13+ will drop it unless a fallback is defined in the manifest.

File: android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.yourcompany.app">

    <!-- Required for Android 13+ -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    
    <!-- Required for network access -->
    <uses-permission android:name="android.permission.INTERNET"/>

    <application
        android:label="Your App"
        android:icon="@mipmap/ic_launcher">
        
        <!-- 
           CRITICAL: Default Notification Channel 
           If the payload doesn't specify a channel_id, this is used.
           Without this, background notifications often fail silently.
        -->
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="high_importance_channel" />

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            
            <!-- Standard Flutter Intent Filter -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

Step 2: The Dart Implementation

The specific architecture of your main.dart determines if your app survives the terminated state. We will use flutter_local_notifications in conjunction with firebase_messaging to ensure we have full control over how the notification renders.

Dependencies

Add the following to your pubspec.yaml:

dependencies:
  firebase_core: ^2.24.2
  firebase_messaging: ^14.7.10
  flutter_local_notifications: ^16.3.0

The Background Handler

This is the most critical section. The background handler must be a top-level function (outside any class) and annotated with @pragma('vm:entry-point'). This annotation prevents the Dart compiler from tree-shaking the function during release builds.

File: lib/main.dart

import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

// 1. Initialize Local Notifications
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
    FlutterLocalNotificationsPlugin();

// 2. Define the Background Handler
// CRITICAL: This must be outside any class and annotated to prevent tree-shaking
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Initialize Firebase manually as the UI is not available
  await Firebase.initializeApp();
  
  // Explicitly handle the message if it's a "Data" message trying to trigger a notification
  // This is often necessary for terminated state reliability
  if (message.notification == null && message.data.isNotEmpty) {
      await _showLocalNotification(message);
  }

  debugPrint("Handling a background message: ${message.messageId}");
}

// Helper to show notification manually (for Data messages)
Future<void> _showLocalNotification(RemoteMessage message) async {
  const AndroidNotificationDetails androidPlatformChannelSpecifics =
      AndroidNotificationDetails(
    'high_importance_channel', // Must match Manifest meta-data
    'High Importance Notifications',
    channelDescription: 'This channel is used for important notifications.',
    importance: Importance.max,
    priority: Priority.high,
    showWhen: true,
  );

  const NotificationDetails platformChannelSpecifics =
      NotificationDetails(android: androidPlatformChannelSpecifics);

  await flutterLocalNotificationsPlugin.show(
    0, // Notification ID
    message.data['title'] ?? 'New Notification',
    message.data['body'] ?? 'You have a new message',
    platformChannelSpecifics,
    payload: 'item_x',
  );
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // 3. Register the Background Handler
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  // 4. Create the Notification Channel (Android 8+)
  // This ensures the channel exists before we try to use it
  await _createNotificationChannel();

  runApp(const MyApp());
}

Future<void> _createNotificationChannel() async {
  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'high_importance_channel', // id
    'High Importance Notifications', // title
    description: 'This channel is used for important notifications.', // description
    importance: Importance.max,
  );

  await flutterLocalNotificationsPlugin
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(channel);
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    _requestPermissions();
    _setupForegroundHandlers();
  }

  // 5. Request Permissions (Android 13+)
  Future<void> _requestPermissions() async {
    FirebaseMessaging messaging = FirebaseMessaging.instance;

    NotificationSettings settings = await messaging.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
      sound: true,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      debugPrint('User granted permission');
    } else if (settings.authorizationStatus == AuthorizationStatus.provisional) {
      debugPrint('User granted provisional permission');
    } else {
      debugPrint('User declined or has not accepted permission');
    }
  }

  void _setupForegroundHandlers() {
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      debugPrint('Got a message whilst in the foreground!');
      debugPrint('Message data: ${message.data}');

      if (message.notification != null) {
        debugPrint('Message also contained a notification: ${message.notification}');
      }
      
      // Manually show notification in foreground if desired
      _showLocalNotification(message);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text("FCM Android 14 Fix")),
        body: const Center(child: Text("Listening for Notifications...")),
      ),
    );
  }
}

Step 3: Constructing the Correct Payload

This is where 50% of implementations fail. There are two types of FCM messages: Notification Messages and Data Messages.

  1. Notification Messages: Automatically handled by the Android system tray only when the app is in the background. They do not trigger onBackgroundMessage effectively for custom logic unless the user taps them.
  2. Data Messages: Trigger onBackgroundMessage regardless of app state.

For maximum reliability on Android 14, utilize a Data Message structure or a mixed structure, and ensure you set the Android Priority to high.

The "Golden" Payload Format

When sending from your backend (Node.js, Go, Python, etc.), use this structure. Do not rely on the Firebase Console test sender for debugging complex background logic, as it sends pure Notification messages.

{
  "to": "DEVICE_FCM_TOKEN",
  "priority": "high",
  "data": {
    "title": "Update Available",
    "body": "Your report is ready to download.",
    "click_action": "FLUTTER_NOTIFICATION_CLICK",
    "screen": "/reports",
    "sound": "default"
  },
  "android": {
    "priority": "high",
    "notification": {
      "channel_id": "high_importance_channel"
    }
  }
}

Key Elements:

  • priority: "high": Tells Android to wake the radio immediately.
  • data: Contains your actual content. In the Dart _firebaseMessagingBackgroundHandler, we extract these fields to build the local notification.
  • channel_id: Explicitly links to the channel we created in main.dart and AndroidManifest.xml.

Deep Dive: Handling "App Kill" vs "Background"

There is a distinct difference between an app that is in the background (minimized) and one that is terminated (killed).

When the app is Backgrounded, the Flutter engine is still in memory. The notification triggers, and the Dart code executes rapidly.

When the app is Terminated, Android must spin up a new process. This takes time.

  1. The System receives the FCM packet.
  2. It looks at the Manifest for the FLUTTER_NOTIFICATION_CLICK intent or the background service definition.
  3. It creates a background isolate.
  4. It seeks the @pragma('vm:entry-point') function.

If you omit the channel_id in your payload or the Manifest metadata, Android 14 (which is stricter about background starts) may decide the notification isn't important enough to warrant spinning up the full process, resulting in a "ghost" notification that appears in logs but not on the screen.

Troubleshooting Checklist

If you are still facing issues, check these specific edge cases:

  1. Emulator vs Real Device: Android Emulators often have erratic behavior with push notifications. Always validate on a real device.
  2. Power Saving Mode: If the device is in "Battery Saver" mode, Android may suppress even high-priority notifications.
  3. ProGuard / R8: If your release build crashes but debug works, your obfuscation rules might be too aggressive. The @pragma('vm:entry-point') usually solves this, but ensure you aren't stripping the Flutter Android embedding classes.
  4. Google Services JSON: Ensure your google-services.json is present in android/app/ and that the package name matches exactly with your build.gradle.

Conclusion

Reliable notifications on Android 14 require a synergy between the Native manifest, the Dart compiler configuration, and the backend payload structure. By strictly defining the Notification Channel, utilizing the VM entry point annotation, and handling permissions explicitly, you can bypass the aggressive battery optimizations that plague modern Android development.