DEV Community

Cover image for Flutter Push Notifications with FCM — Complete 2026 Walkthrough
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

Flutter Push Notifications with FCM — Complete 2026 Walkthrough

Every project I touch eventually needs push notifications, and every project asks the same question: why does the notification work in the foreground but vanish in the background? So, in this article, I will be showing you how you can set up Flutter push notifications with Firebase Cloud Messaging (FCM) the complete way — foreground, background, and terminated states — including the 2026 gotchas: the Android 13 runtime permission, the FCM HTTP v1 API, and the background handler that half the tutorials skip.

For this purpose, we need to add these dependencies in your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^3.8.0
  firebase_messaging: ^15.1.0
  flutter_local_notifications: ^18.0.0
Enter fullscreen mode Exit fullscreen mode
  • firebase_core initializes Firebase in your app — mandatory first step.
  • firebase_messaging handles receiving FCM messages and the device token.
  • flutter_local_notifications shows notifications for messages received while the app is in the foreground — a step everyone forgets, because FCM does not display foreground messages by default.

The firebase_messaging package's own documentation points you to flutter_local_notifications for exactly this reason, and skipping it is the #1 cause of "notifications work when the app is closed but not when I open it."

Let's jump into the coding part.

Step 1: Firebase Project Setup

Before any Dart, the project-side setup, because it is where most people stall:

  1. Create a Firebase project in the Firebase console and add your Android (package name) and iOS (bundle ID) apps.
  2. Download google-services.json and drop it into android/app/, and GoogleService-Info.plist into ios/Runner/.
  3. Add the Google services Gradle plugin to android/build.gradle:
// android/build.gradle
buildscript {
  dependencies {
    classpath 'com.google.gms:google-services:4.4.2'
  }
}
Enter fullscreen mode Exit fullscreen mode

and apply it at the bottom of android/app/build.gradle:

apply plugin: 'com.google.gms.google-services'
Enter fullscreen mode Exit fullscreen mode
  1. For iOS, update your AppDelegate to let Flutter know the app is done launching (needed for background notifications):
// ios/Runner/AppDelegate.swift
import FirebaseCore

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(_ application: UIApplication,
                            didFinishLaunchingWithOptions launchOptions:
                            [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    FirebaseApp.configure()
    if #available(iOS 10.0, *) {
      UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
    }
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
Enter fullscreen mode Exit fullscreen mode

On iOS you must also upload your APNs key to Firebase (Project settings → Cloud Messaging) or FCM cannot deliver to iPhones. This is the most common silent failure: everything works on Android, nothing arrives on iOS, and the reason is a missing APNs key.

Step 2: Initialize Firebase and Request Permission

Now the Dart side. Initialize Firebase before anything else, request notification permission, and grab the device token:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';

Future<void> setupPush() async {
  await Firebase.initializeApp();
  final messaging = FirebaseMessaging.instance;

  // Android 13+ requires a runtime permission (API 33+)
  final settings = await messaging.requestPermission(
    alert: true, badge: true, sound: true,
  );
  debugPrint('Granted: ${settings.authorizationStatus}');

  // Register for remote messages (needed on iOS too)
  await messaging.setForegroundNotificationPresentationOptions(
    alert: true, badge: true, sound: true,
  );

  // Get the device token to send to your backend
  final token = await messaging.getToken();
  // Send `token` to your server and store it.
  debugPrint('FCM Token: $token');
}
Enter fullscreen mode Exit fullscreen mode

On Android 13 and newer, requestPermission() triggers the runtime dialog the OS requires — without it, notifications are silently blocked. On older Android and on iOS, this maps to the appropriate system permission. Run this in main() after WidgetsFlutterBinding.ensureInitialized().

Step 3: The Foreground Message Handler

FCM does not show a notification when the app is in the foreground — it only delivers the data to your handler. So we forward it to flutter_local_notifications:

import 'package:flutter_local_notifications/flutter_local_notifications.dart';

final _local = FlutterLocalNotificationsPlugin();

Future<void> initLocalNotifications() async {
  const init = InitializationSettings(
    android: AndroidInitializationSettings('@mipmap/ic_launcher'),
    iOS: DarwinInitializationSettings(),
  );
  await _local.initialize(init);
}

Future<void> showForegroundNotification(RemoteMessage message) async {
  await _local.show(
    message.hashCode,
    message.notification?.title ?? 'Update',
    message.notification?.body ?? '',
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'main_channel', 'General',
        channelDescription: 'General notifications',
        importance: Importance.high,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Then, in your setup, listen for foreground messages and route them:

FirebaseMessaging.onMessage.listen(showForegroundNotification);
Enter fullscreen mode Exit fullscreen mode

Step 4: The Background and Terminated-State Handler

This is the part that gets skipped, and it is why notifications "disappear" when the app is closed. A message that arrives when the app is backgrounded or terminated is delivered to a top-level handler — a function outside your widget tree, exactly like the workmanager callback in my background-tasks guide:

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Do NOT touch UI here. Log, or queue work.
  debugPrint('Background message: ${message.notification?.title}');
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
  runApp(const MyApp());
}
Enter fullscreen mode Exit fullscreen mode

The @pragma('vm:entry-point') annotation is essential — without it, release builds can strip the handler and background messages silently do nothing. When the app is terminated, the OS launches a minimal isolate to run this handler; it has a few seconds and no UI access. If you need to handle taps that open the app, listen to FirebaseMessaging.onMessageOpenedApp and handle messaging.getInitialMessage() for terminated-state launches.

Step 5: Sending a Notification — the 2026 Way

In 2026, send using FCM's HTTP v1 API (the legacy send endpoint is deprecated). Example using the legacy-free approach with a server service account:

# Server side — use an OAuth2 token from your service account
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": "<DEVICE_FCM_TOKEN>",
      "notification": {
        "title": "Order shipped",
        "body": "Your order #4821 is on the way"
      },
      "android": { "notification": { "channel_id": "main_channel" } }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

Two things matter here. First, the android.notification.channel_id must match the channel name from your AndroidNotificationDetails — mismatch means the notification arrives with the app's default channel and bad behavior. Second, for Android 13+ you can include android.notification.priority and rely on the channel importance you set in the app, not the payload.

Important Notes — The Failure Modes I Have Paid For

  1. Foreground messages are never shown by FCM. If you did not wire onMessage into flutter_local_notifications, foreground notifications appear to be "broken." They are not broken — they are being delivered to a handler that does nothing.
  2. Android 13 blocks notifications without the runtime permission. Test on a physical Android 13+ device, not an emulator. If requestPermission() returns denied, no notification will ever show, and no amount of payload tuning fixes it.
  3. The background handler must be top-level with @pragma('vm:entry-point'). A closure that references widget state will crash or no-op in the background isolate.
  4. Token refresh. FCM tokens rotate. Listen to messaging.onTokenRefresh and update your backend, or users quietly stop receiving notifications after a reinstall.
  5. iOS needs the APNs key configured in Firebase, or Android works and iOS is dead silent. Also register the background modes in your Xcode project if you need data-only messages.
  6. Do not trust the emulator for delivery. Emulators often have unreliable FCM delivery. Physical devices behave differently; test there.
  7. Keep notification payloads small. A 4KB notification payload is the FCM limit. If you need to send rich data, send an ID and fetch the rest over your API when the notification is tapped.

Step 6: Handle Token Refresh and Notification Taps

Two pieces of the wiring complete the picture, and both are usually missing from tutorials.

Token refresh. FCM tokens rotate — after a reinstall, an app update, or on some OS quirks. Listen for it and push the new token to your backend, or your users silently stop getting notifications:

FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
  // Send newToken to your backend, replacing the old one.
  debugPrint('Token refreshed: $newToken');
});
Enter fullscreen mode Exit fullscreen mode

Tap handling with navigation. When the user taps a notification you usually want to deep-link somewhere. The message payload's data map is where you put a route or ID, and you handle taps in the foreground and the backgrounded/terminated states separately:

void setupTapHandlers() {
  // App was in foreground when tapped
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    final route = message.data['route'];
    navigatorKey.currentState?.pushNamed(route ?? '/');
  });

  // App was terminated when tapped — check on launch
  FirebaseMessaging.instance.getInitialMessage().then((message) {
    if (message != null) {
      final route = message.data['route'];
      navigatorKey.currentState?.pushNamed(route ?? '/');
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Keep a GlobalKey<NavigatorState> at the app root so the handler can navigate even when it runs outside a widget's context. Data messages (a data field without a notification field) are the reliable way to carry this routing payload, because FCM delivers them to your handler on every state — foreground, background, and terminated.

Step 7: Subscribing to Topics

Instead of sending to individual device tokens, FCM supports topics — one message fans out to every subscribed device. This is how I shipped a weekly digest for a client without managing a token list:

await FirebaseMessaging.instance.subscribeToTopic('digest-weekly');
await FirebaseMessaging.instance.unsubscribeFromTopic('digest-weekly');
Enter fullscreen mode Exit fullscreen mode

Send to a topic with the same HTTP v1 endpoint, replacing "token" with "topic": "digest-weekly". Topics are perfect for broadcast-style notifications (news, offers, digests) and dead simple at scale. The catch: everyone subscribed to a topic gets the same message, so personalization still needs a token-based send.

The 2026 Checklist — Go Through This Before You Ship

  • [ ] google-services.json in android/app/ and Gradle plugin applied
  • [ ] iOS: GoogleService-Info.plist in ios/Runner/, APNs key uploaded in Firebase
  • [ ] iOS: FirebaseApp.configure() in AppDelegate, notification delegate set
  • [ ] Android 13+ runtime permission requested and granted on a physical device
  • [ ] Foreground messages routed through flutter_local_notifications
  • [ ] Top-level background handler with @pragma('vm:entry-point')
  • [ ] onMessageOpenedApp + getInitialMessage handle taps in every state
  • [ ] onTokenRefresh updates your backend
  • [ ] Payload data carries a route key, tested on a locked, backgrounded device
  • [ ] Sends use the FCM HTTP v1 API, not the deprecated legacy endpoint

Data-Only Messages vs Notification Messages — Pick Deliberately

FCM has two message shapes, and choosing wrong is the root of half the "notification disappeared" bugs I debug.

A notification message (a notification field) is handled by the OS when the app is backgrounded or terminated — FCM itself builds the notification, and your Dart handler is not guaranteed to run for display purposes. A data-only message (only a data field, no notification) is delivered to your Dart handler in every state — foreground, background, and terminated — and you decide what to show, or whether to show anything.

My rule: use notification messages for simple alerts you want the OS to display with zero code, and data-only messages whenever you need custom behavior — routing, localization, deciding on-device whether the notification is relevant. The tap-handling code above relies on data, so if you only ever send notification messages, you will have nothing to read in onMessageOpenedApp. When in doubt, send both: a small notification for OS display plus a data payload with your routing keys.

For quick manual tests, the Firebase console's Cloud Messaging section lets you compose and send a message without writing any server code — target your physical device by token and confirm each state (foreground, background, terminated) before you involve the backend. That ten-minute manual pass has caught more misconfiguration for me than any amount of code reading, because it isolates the problem: if the console send fails to display, the bug is in your app wiring; if it displays but your API send does not, the bug is server-side.

That's it — a complete FCM push notification integration for 2026: Firebase project setup, Android 13 permission, device token, foreground routing through local notifications, the top-level background handler, and a working HTTP v1 send. The four-file checklist is: google-services.json, the Gradle plugin, the iOS AppDelegate, and the main.dart wiring — and the background handler is the one that makes the difference between "works on my desk" and "works on a locked phone in a pocket."

I have also covered local scheduled notifications and background tasks with this exact stack — comment below with the notification feature you are stuck on and I'll cover it next.


*Gulshan Yad

Top comments (0)