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
-
firebase_coreinitializes Firebase in your app — mandatory first step. -
firebase_messaginghandles receiving FCM messages and the device token. -
flutter_local_notificationsshows 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:
- Create a Firebase project in the Firebase console and add your Android (package name) and iOS (bundle ID) apps.
- Download
google-services.jsonand drop it intoandroid/app/, andGoogleService-Info.plistintoios/Runner/. - Add the Google services Gradle plugin to
android/build.gradle:
// android/build.gradle
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.2'
}
}
and apply it at the bottom of android/app/build.gradle:
apply plugin: 'com.google.gms.google-services'
- 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)
}
}
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');
}
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(),
),
);
}
Then, in your setup, listen for foreground messages and route them:
FirebaseMessaging.onMessage.listen(showForegroundNotification);
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());
}
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" } }
}
}'
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
-
Foreground messages are never shown by FCM. If you did not wire
onMessageintoflutter_local_notifications, foreground notifications appear to be "broken." They are not broken — they are being delivered to a handler that does nothing. -
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. -
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. -
Token refresh. FCM tokens rotate. Listen to
messaging.onTokenRefreshand update your backend, or users quietly stop receiving notifications after a reinstall. -
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-onlymessages. - Do not trust the emulator for delivery. Emulators often have unreliable FCM delivery. Physical devices behave differently; test there.
- 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');
});
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 ?? '/');
}
});
}
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');
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.jsoninandroid/app/and Gradle plugin applied - [ ] iOS:
GoogleService-Info.plistinios/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+getInitialMessagehandle taps in every state - [ ]
onTokenRefreshupdates your backend - [ ] Payload
datacarries 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
This article was originally published by DEV Community and written by Gulshan Yadav.
Read original article on DEV Community